diff options
author | Ricky Kresslein <ricky@kressle.in> | 2022-02-18 16:14:09 +0100 |
---|---|---|
committer | Ricky Kresslein <ricky@kressle.in> | 2022-02-18 16:14:09 +0100 |
commit | ac99aca2510eebb83ac9a112849d8788ef67db3d (patch) | |
tree | 57bbddbf10a83f8abd5e5020b897ed80dc7d7e44 /src | |
download | Furtherance-ac99aca2510eebb83ac9a112849d8788ef67db3d.tar.zst |
- Moved to com.lakoliu.Furtherance
- Removed development flag for release - Created nicer about dialog - Added description to data file - Improved desktop file - Changed database directory - Delete All no longer enabled if no tasks - Added GPL to top of every file
Diffstat (limited to 'src')
-rw-r--r-- | src/application.rs | 183 | ||||
-rw-r--r-- | src/config.rs.in | 21 | ||||
-rw-r--r-- | src/database.rs | 183 | ||||
-rw-r--r-- | src/furtherance.gresource.xml | 12 | ||||
-rw-r--r-- | src/gtk/history_box.ui | 87 | ||||
-rw-r--r-- | src/gtk/style.css | 21 | ||||
-rw-r--r-- | src/gtk/task_details.ui | 138 | ||||
-rw-r--r-- | src/gtk/task_row.ui | 39 | ||||
-rw-r--r-- | src/gtk/tasks_group.ui | 10 | ||||
-rw-r--r-- | src/gtk/tasks_page.ui | 5 | ||||
-rw-r--r-- | src/gtk/window.ui | 95 | ||||
-rw-r--r-- | src/main.rs | 58 | ||||
-rw-r--r-- | src/meson.build | 67 | ||||
-rw-r--r-- | src/ui.rs | 29 | ||||
-rw-r--r-- | src/ui/history_box.rs | 138 | ||||
-rw-r--r-- | src/ui/task_details.rs | 441 | ||||
-rw-r--r-- | src/ui/task_row.rs | 130 | ||||
-rw-r--r-- | src/ui/tasks_group.rs | 112 | ||||
-rw-r--r-- | src/ui/tasks_page.rs | 147 | ||||
-rw-r--r-- | src/ui/window.rs | 330 |
20 files changed, 2246 insertions, 0 deletions
diff --git a/src/application.rs b/src/application.rs new file mode 100644 index 0000000..1f5d345 --- /dev/null +++ b/src/application.rs @@ -0,0 +1,183 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use glib::clone; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{gdk, gio, glib}; + +use crate::config; +use crate::ui::FurtheranceWindow; +use crate::database; + +mod imp { + use super::*; + + #[derive(Debug, Default)] + pub struct FurtheranceApplication {} + + #[glib::object_subclass] + impl ObjectSubclass for FurtheranceApplication { + const NAME: &'static str = "FurtheranceApplication"; + type Type = super::FurtheranceApplication; + type ParentType = gtk::Application; + } + + impl ObjectImpl for FurtheranceApplication { + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + obj.setup_gactions(); + obj.set_accels_for_action("app.quit", &["<primary>Q", "<primary>W"]); + } + } + + impl ApplicationImpl for FurtheranceApplication { + // We connect to the activate callback to create a window when the application + // has been launched. Additionally, this callback notifies us when the user + // tries to launch a "second instance" of the application. When they try + // to do that, we'll just present any existing window. + fn activate(&self, application: &Self::Type) { + // Initialize the database + let _ = database::db_init(); + + // Get the current window or create one if necessary + let window = if let Some(window) = application.active_window() { + window + } else { + let window = FurtheranceWindow::new(application); + window.set_default_size(400, 600); + window.set_title(Some("Furtherance")); + window.upcast() + }; + + // Load style.css + let css_file = gtk::CssProvider::new(); + gtk::CssProvider::load_from_resource(&css_file, "/com/lakoliu/Furtherance/gtk/style.css"); + gtk::StyleContext::add_provider_for_display(&gdk::Display::default().unwrap(), &css_file, 500); + + // Ask the window manager/compositor to present the window + window.present(); + } + } + + impl GtkApplicationImpl for FurtheranceApplication {} +} + +glib::wrapper! { + pub struct FurtheranceApplication(ObjectSubclass<imp::FurtheranceApplication>) + @extends gio::Application, gtk::Application, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl FurtheranceApplication { + pub fn new(application_id: &str, flags: &gio::ApplicationFlags) -> Self { + glib::Object::new(&[("application-id", &application_id), ("flags", flags)]) + .expect("Failed to create FurtheranceApplication") + } + + fn setup_gactions(&self) { + let quit_action = gio::SimpleAction::new("quit", None); + quit_action.connect_activate(clone!(@weak self as app => move |_, _| { + app.quit(); + })); + self.add_action(&quit_action); + + let about_action = gio::SimpleAction::new("about", None); + about_action.connect_activate(clone!(@weak self as app => move |_, _| { + app.show_about(); + })); + self.add_action(&about_action); + } + + fn show_about(&self) { + let window = self.active_window().unwrap(); + let dialog = gtk::AboutDialog::builder() + .transient_for(&window) + .modal(true) + .program_name("Furtherance") + .logo_icon_name(config::APP_ID) + .version(config::VERSION) + .comments("Track your time without being tracked.") + .copyright("© 2022 Ricky Kresslein") + .authors(vec!["Ricky Kresslein".into()]) + // .website("https://furtherance.app") + .license_type(gtk::License::Gpl30) + .build(); + + dialog.present(); + } + + fn delete_history(&self) { + // Show dialog to delete all history + let window = FurtheranceWindow::default(); + let dialog = gtk::MessageDialog::with_markup( + Some(&window), + gtk::DialogFlags::MODAL, + gtk::MessageType::Question, + gtk::ButtonsType::None, + Some("<span size='x-large' weight='bold'>Delete history?</span>"), + ); + dialog.add_buttons(&[ + ("Cancel", gtk::ResponseType::Reject), + ("Delete", gtk::ResponseType::Accept) + ]); + + let message_area = dialog.message_area().downcast::<gtk::Box>().unwrap(); + let explanation = gtk::Label::new(Some("This will delete ALL of your task history.")); + let instructions = gtk::Label::new(Some( + "Type DELETE in the box below then click Delete to proceed.")); + let delete_entry = gtk::Entry::new(); + message_area.append(&explanation); + message_area.append(&instructions); + message_area.append(&delete_entry); + + dialog.connect_response(clone!(@weak dialog = > move |_, resp| { + if resp == gtk::ResponseType::Accept { + if delete_entry.text().to_uppercase() == "DELETE" { + let _ = database::delete_all(); + window.reset_history_box(); + dialog.close(); + } + } else { + dialog.close(); + } + })); + + dialog.show(); + } + + pub fn delete_enabled(&self, enabled: bool) { + if enabled { + let delete_history_action = gio::SimpleAction::new("delete-history", None); + delete_history_action.connect_activate(clone!(@weak self as app => move |_, _| { + app.delete_history(); + })); + self.add_action(&delete_history_action); + } else { + self.remove_action("delete-history"); + } + } +} + +impl Default for FurtheranceApplication { + fn default() -> Self { + gio::Application::default() + .expect("Could not get default GApplication") + .downcast() + .unwrap() + } +} diff --git a/src/config.rs.in b/src/config.rs.in new file mode 100644 index 0000000..4005031 --- /dev/null +++ b/src/config.rs.in @@ -0,0 +1,21 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +pub static VERSION: &str = @VERSION@; +pub static GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; +pub static LOCALEDIR: &str = @LOCALEDIR@; +pub static PKGDATADIR: &str = @PKGDATADIR@; +pub static APP_ID: &str = @APP_ID@; diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..70e6043 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,183 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use rusqlite::{Connection, Result}; +use chrono::{DateTime, Local}; +use directories::ProjectDirs; +use std::path::PathBuf; +use std::fs::create_dir_all; + +#[derive(Clone, Debug)] +pub struct Task { + pub id: i32, + pub task_name: String, + pub start_time: String, + pub stop_time: String, +} + +pub fn get_directory() -> PathBuf { + if let Some(proj_dirs) = ProjectDirs::from("com", "lakoliu", "Furtherance") { + let mut path = PathBuf::from(proj_dirs.data_dir()); + create_dir_all(path.clone()).expect("Unable to create database directory"); + path.extend(&["furtherance.db"]); + return path + } + PathBuf::new() +} + +pub fn db_init() -> Result<()> { + let conn = Connection::open(get_directory())?; + conn.execute( + "CREATE TABLE tasks ( + id integer primary key, + task_name text, + start_time timestamp, + stop_time timestamp)", + [], + )?; + + Ok(()) +} + + +pub fn db_write(task_name: &str, start_time: DateTime<Local>, stop_time: DateTime<Local>) -> Result<()> { + // Write data into database + let conn = Connection::open(get_directory())?; + + conn.execute( + "INSERT INTO tasks (task_name, start_time, stop_time) values (?1, ?2, ?3)", + &[&task_name.to_string(), &start_time.to_rfc3339(), &stop_time.to_rfc3339()], + )?; + + Ok(()) +} + +pub fn retrieve() -> Result<Vec<Task>, rusqlite::Error> { + // Retrieve all tasks from the database + let conn = Connection::open(get_directory())?; + + let mut query = conn.prepare("SELECT * FROM tasks ORDER BY start_time")?; + let task_iter = query.query_map([], |row| { + Ok(Task { + id: row.get(0)?, + task_name: row.get(1)?, + start_time: row.get(2)?, + stop_time: row.get(3)?, + }) + })?; + + let mut tasks_vec: Vec<Task> = Vec::new(); + for task_item in task_iter { + tasks_vec.push(task_item.unwrap()); + } + + Ok(tasks_vec) + +} + +pub fn update_start_time(id: i32, start_time: String) -> Result<()> { + let conn = Connection::open(get_directory())?; + + conn.execute( + "UPDATE tasks SET start_time = (?1) WHERE id = (?2)", + &[&start_time, &id.to_string()] + )?; + + Ok(()) +} + +pub fn update_stop_time(id: i32, stop_time: String) -> Result<()> { + let conn = Connection::open(get_directory())?; + + conn.execute( + "UPDATE tasks SET stop_time = (?1) WHERE id = (?2)", + &[&stop_time, &id.to_string()] + )?; + + Ok(()) +} + +pub fn update_task_name(id: i32, task_name: String) -> Result<()> { + let conn = Connection::open(get_directory())?; + + conn.execute( + "UPDATE tasks SET task_name = (?1) WHERE id = (?2)", + &[&task_name, &id.to_string()] + )?; + + Ok(()) +} + +pub fn get_list_by_id(id_list: Vec<i32>) -> Result<Vec<Task>, rusqlite::Error> { + let conn = Connection::open(get_directory())?; + let mut tasks_vec: Vec<Task> = Vec::new(); + + for id in id_list { + let mut query = conn.prepare( + "SELECT * FROM tasks WHERE id = :id;")?; + let task_iter = query.query_map(&[(":id", &id.to_string())], |row| { + Ok(Task { + id: row.get(0)?, + task_name: row.get(1)?, + start_time: row.get(2)?, + stop_time: row.get(3)?, + }) + })?; + + for task_item in task_iter { + tasks_vec.push(task_item.unwrap()); + } + } + + Ok(tasks_vec) +} + +pub fn check_for_tasks() -> Result<String> { + let conn = Connection::open(get_directory())?; + + conn.query_row( + "SELECT task_name FROM tasks WHERE id='1'", + [], + |row| row.get(0), + ) +} + +pub fn delete_by_ids(id_list: Vec<i32>) -> Result<()> { + let conn = Connection::open(get_directory())?; + + for id in id_list { + conn.execute("delete FROM tasks WHERE id = (?1)", &[&id.to_string()])?; + } + + Ok(()) +} + +pub fn delete_by_id(id: i32) -> Result<()> { + let conn = Connection::open(get_directory())?; + + conn.execute("delete FROM tasks WHERE id = (?1)", &[&id.to_string()])?; + + Ok(()) +} + +pub fn delete_all() -> Result<()> { + // Delete everything from the database + let conn = Connection::open(get_directory())?; + + conn.execute("delete from tasks",[],)?; + + Ok(()) +} diff --git a/src/furtherance.gresource.xml b/src/furtherance.gresource.xml new file mode 100644 index 0000000..0116668 --- /dev/null +++ b/src/furtherance.gresource.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<gresources> + <gresource prefix="/com/lakoliu/Furtherance"> + <file>gtk/window.ui</file> + <file>gtk/history_box.ui</file> + <file>gtk/style.css</file> + <file>gtk/tasks_page.ui</file> + <file>gtk/tasks_group.ui</file> + <file>gtk/task_row.ui</file> + <file>gtk/task_details.ui</file> + </gresource> +</gresources> diff --git a/src/gtk/history_box.ui b/src/gtk/history_box.ui new file mode 100644 index 0000000..b761624 --- /dev/null +++ b/src/gtk/history_box.ui @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="FurHistoryBox" parent="GtkBox"> + <child> + <object class="GtkStack" id="stack"> + <property name="transition_type">crossfade</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <child> + <object class="GtkStackPage"> + <property name="name">loading</property> + <property name="child"> + <object class="GtkSpinner" id="spinner"> + <property name="halign">center</property> + <property name="width-request">32</property> + </object> + </property> + </object> + </child> + <child> + <object class="GtkStackPage"> + <property name="name">empty</property> + <property name="child"> + <object class="AdwStatusPage" id="welcome_page"> + <property name="title" translatable="yes">Start Tracking</property> + <property name="icon_name">com.lakoliu.Furtherance</property> + <property name="child"> + <object class="GtkGrid"> + <property name="halign">center</property> + <property name="row_spacing">12</property> + <property name="column_spacing">12</property> + <child> + <object class="GtkImage"> + <property name="icon_name">list-add-symbolic</property> + <layout> + <property name="column">0</property> + <property name="row">0</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="halign">start</property> + <property name="label" translatable="yes">Type your task and press start</property> + <layout> + <property name="column">1</property> + <property name="row">0</property> + </layout> + </object> + </child> + <child> + <object class="GtkImage"> + <property name="icon_name">window-new-symbolic</property> + <layout> + <property name="column">0</property> + <property name="row">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="halign">start</property> + <property name="label" translatable="yes">Prior tasks will show up here</property> + <layout> + <property name="column">1</property> + <property name="row">1</property> + </layout> + </object> + </child> + </object> + </property> + </object> + </property> + </object> + </child> + <child> + <object class="GtkStackPage"> + <property name="name">tasks</property> + <property name="child"> + <object class="FurTasksPage" id="tasks_page"/> + </property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gtk/style.css b/src/gtk/style.css new file mode 100644 index 0000000..5681ce3 --- /dev/null +++ b/src/gtk/style.css @@ -0,0 +1,21 @@ +.welcome-icon { + transform: scale(2.5); +} + +.task-details headerbar { + transition: 200ms ease-out; + transition-property: box-shadow, background-color; +} + +.task-details headerbar.hidden { + background-color: transparent; + box-shadow: inset 0 -1px transparent; +} + +.inactive-button, .inactive-button:hover { + background-color: #f3f3f3; +} + +.error_message { + color: red; +} diff --git a/src/gtk/task_details.ui b/src/gtk/task_details.ui new file mode 100644 index 0000000..ccbfc1e --- /dev/null +++ b/src/gtk/task_details.ui @@ -0,0 +1,138 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="FurTaskDetails" parent="AdwWindow"> + <property name="width-request">350</property> + <property name="height-request">400</property> + <property name="default-width">350</property> + <property name="default-height">550</property> + <property name="title" translatable="yes">Task Details</property> + <property name="modal">True</property> + <style> + <class name="task-details"/> + </style> + <child> + <object class="GtkOverlay"> + <child type="overlay"> + <object class="GtkHeaderBar" id="headerbar"> + <property name="valign">start</property> + <property name="title-widget"> + <object class="AdwWindowTitle" id="dialog_title"> + <property name="visible">False</property> + </object> + </property> + <style> + <class name="hidden"/> + </style> + </object> + </child> + <child> + <object class="GtkScrolledWindow" id="scrolled_window"> + <property name="vexpand">True</property> + <property name="hscrollbar-policy">never</property> + <style> + <class name="flat-headerbar"/> + </style> + <child> + <object class="AdwClamp"> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="margin-top">24</property> + <property name="margin-bottom">24</property> + <property name="maximum-size">500</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">18</property> + <child> + <object class="AdwClamp"> + <property name="maximum-size">400</property> + <property name="margin-top">48</property> + <property name="tightening-threshold">200</property> + <child> + <object class="GtkLabel" id="task_name_label"> + <property name="wrap">True</property> + <property name="justify">center</property> + <property name="ellipsize">end</property> + <property name="lines">3</property> + <property name="wrap-mode">word-char</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="main_box"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <child> + <object class="GtkBox"> + <property name="hexpand">True</property> + <property name="homogeneous">True</property> + <property name="spacing">5</property> + <child> + <object class="GtkLabel"> + <property name="label">Start</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label">Stop</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label">Total</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + <child type="overlay"> + <object class="GtkBox"> + <property name="spacing">12</property> + <property name="margin-end">12</property> + <property name="margin-bottom">12</property> + <property name="orientation">vertical</property> + <property name="halign">end</property> + <property name="valign">end</property> + <child> + <object class="GtkButton" id="delete_all_btn"> + <property name="icon-name">user-trash-symbolic</property> + <property name="tooltip-text" translatable="yes">Delete all</property> + <style> + <class name="delete-all-button"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkShortcutController"> + <property name="scope">local</property> + <child> + <object class="GtkShortcut"> + <property name="trigger">Escape</property> + <property name="action">action(window.close)</property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gtk/task_row.ui b/src/gtk/task_row.ui new file mode 100644 index 0000000..edd2899 --- /dev/null +++ b/src/gtk/task_row.ui @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="FurTaskRow" parent="GtkListBoxRow"> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="margin_top">10</property> + <property name="margin_bottom">10</property> + <property name="margin_end">12</property> + <property name="margin_start">12</property> + <property name="hexpand">True</property> + <property name="spacing">3</property> + <property name="valign">center</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkLabel" id="task_name_label"> + <property name="halign">start</property> + <property name="label" translatable="yes">Task</property> + <property name="ellipsize">end</property> + <property name="single_line_mode">True</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="total_time_label"> + <property name="halign">end</property> + <property name="label" translatable="yes">Time</property> + <property name="single_line_mode">True</property> + <style> + <class name="numeric"/> + </style> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gtk/tasks_group.ui b/src/gtk/tasks_group.ui new file mode 100644 index 0000000..be43050 --- /dev/null +++ b/src/gtk/tasks_group.ui @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="FurTasksGroup" parent="AdwPreferencesGroup"> + <child> + <object class="GtkBox" id="listbox_box"> + <property name="orientation">vertical</property> + </object> + </child> + </template> +</interface> diff --git a/src/gtk/tasks_page.ui b/src/gtk/tasks_page.ui new file mode 100644 index 0000000..dcd6513 --- /dev/null +++ b/src/gtk/tasks_page.ui @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="FurTasksPage" parent="AdwPreferencesPage"> + </template> +</interface> diff --git a/src/gtk/window.ui b/src/gtk/window.ui new file mode 100644 index 0000000..d6c5311 --- /dev/null +++ b/src/gtk/window.ui @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk" version="4.0"/> + <template class="FurtheranceWindow" parent="AdwApplicationWindow"> + <property name="title">Furtherance</property> + <property name="content"> + <object class="AdwToastOverlay" id="toast_overlay"> + <property name="child"> + <object class="GtkBox" id="main_box"> + <property name="orientation">vertical</property> + <child> + <object class="AdwHeaderBar" id="header_bar"> + <property name="title-widget"> + <object class="AdwWindowTitle" id="window_title"> + <property name="title">Furtherance</property> + </object> + </property> + <child type="end"> + <object class="GtkMenuButton" id="app_menu_button"> + <property name="tooltip_text" translatable="yes">Main Menu</property> + <property name="icon_name">open-menu-symbolic</property> + <property name="menu_model">primary_menu</property> + </object> + </child> + <style> + <class name="titlebar"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="win_box"> + <property name="orientation">vertical</property> + <property name="spacing">10</property> + <property name="margin_bottom">18</property> + <property name="halign">center</property> + <property name="width_request">400</property> + <property name="vexpand">True</property> + <child> + <object class="GtkLabel" id="watch"> + <property name="label">00:00:00</property> + <attributes> + <attribute name="weight" value="bold"/> + <attribute name="scale" value="5"/> + </attributes> + <style> + <class name="numeric"/> + </style> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="spacing">5</property> + <property name="margin_start">12</property> + <property name="margin_end">8</property> + <child> + <object class="GtkEntry" id="task_input"> + <property name="placeholder-text" translatable="yes">Task Name</property> + <property name="hexpand">True</property> + <property name="hexpand-set">True</property> + </object> + </child> + <child> + <object class="GtkButton" id="start_button"> + <property name="icon-name">media-playback-start-symbolic</property> + </object> + </child> + </object> + </child> + <child> + <object class="FurHistoryBox" id="history_box" /> + </child> + </object> + </child> + </object> + </property> + </object> + </property> + </template> + <menu id="primary_menu"> + <section> + <item> + <attribute name="label" translatable="yes">_Preferences</attribute> + <attribute name="action">app.preferences</attribute> + </item> + <item> + <attribute name="label" translatable="yes">_About Furtherance</attribute> + <attribute name="action">app.about</attribute> + </item> + <item> + <attribute name="label" translatable="yes">_Delete history</attribute> + <attribute name="action">app.delete-history</attribute> + </item> + </section> + </menu> +</interface> diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..541e42c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,58 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +mod application; +mod config; +mod ui; +mod database; + +use self::application::FurtheranceApplication; + +use config::{GETTEXT_PACKAGE, LOCALEDIR, PKGDATADIR}; +use gettextrs::{bind_textdomain_codeset, bindtextdomain, textdomain}; +use gtk::{gio, glib}; +use gtk::prelude::*; + +fn main() { + // Initialize GTK + gtk::init().expect("Failed to initialize GTK."); + // Initialize libadwaita + adw::init(); + + // Set up gettext translations + bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain"); + bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8") + .expect("Unable to set the text domain encoding"); + textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain"); + + // Load resources + let resources = gio::Resource::load(PKGDATADIR.to_owned() + "/furtherance.gresource") + .expect("Could not load resources"); + gio::resources_register(&resources); + + // Create a new GtkApplication. The application manages our main loop, + // application windows, integration with the window manager/compositor, and + // desktop features such as file opening and single-instance applications. + let app = FurtheranceApplication::new("com.lakoliu.Furtherance", &gio::ApplicationFlags::empty()); + + glib::set_application_name("Furtherance"); + + // Run the application. This function will block until the application + // exits. Upon return, we have our exit code to return to the shell. (This + // is the code you see when you do `echo $?` after running a command in a + // terminal. + std::process::exit(app.run()); +} diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..7833e8f --- /dev/null +++ b/src/meson.build @@ -0,0 +1,67 @@ +pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) +gnome = import('gnome') + +gnome.compile_resources('furtherance', + 'furtherance.gresource.xml', + gresource_bundle: true, + install: true, + install_dir: pkgdatadir, +) + +conf = configuration_data() +conf.set_quoted('VERSION', meson.project_version()) +conf.set_quoted('GETTEXT_PACKAGE', 'furtherance') +conf.set_quoted('LOCALEDIR', join_paths(get_option('prefix'), get_option('localedir'))) +conf.set_quoted('PKGDATADIR', pkgdatadir) +conf.set_quoted('APP_ID', app_id) + +configure_file( + input: 'config.rs.in', + output: 'config.rs', + configuration: conf +) + +# Copy the config.rs output to the source directory. +run_command( + 'cp', + join_paths(meson.build_root(), 'src', 'config.rs'), + join_paths(meson.source_root(), 'src', 'config.rs'), + check: true +) + +rust_sources = files( + 'ui.rs', + 'ui/task_details.rs', + 'ui/task_row.rs', + 'ui/tasks_group.rs', + 'ui/tasks_page.rs', + 'ui/history_box.rs', + 'ui/window.rs', + + 'application.rs', + 'config.rs', + 'main.rs', + + 'database.rs', +) + +sources = [cargo_sources, rust_sources] + +cargo_script = find_program(join_paths(meson.source_root(), 'build-aux/cargo.sh')) +cargo_release = custom_target( + 'cargo-build', + build_by_default: true, + input: sources, + output: meson.project_name(), + console: true, + install: true, + install_dir: get_option('bindir'), + command: [ + cargo_script, + meson.build_root(), + meson.source_root(), + '@OUTPUT@', + get_option('buildtype'), + meson.project_name(), + ] +) diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..e1cf7fe --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,29 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +pub mod window; +mod history_box; +mod tasks_page; +mod tasks_group; +mod task_row; +mod task_details; + +pub use window::FurtheranceWindow; +pub use history_box::FurHistoryBox; +pub use tasks_page::FurTasksPage; +pub use tasks_group::FurTasksGroup; +pub use task_row::FurTaskRow; +pub use task_details::FurTaskDetails; diff --git a/src/ui/history_box.rs b/src/ui/history_box.rs new file mode 100644 index 0000000..4de6dce --- /dev/null +++ b/src/ui/history_box.rs @@ -0,0 +1,138 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; +use glib::subclass; + +use crate::ui::FurTasksPage; +use crate::FurtheranceApplication; +use crate::database; + +enum View { + Loading, + Empty, + Tasks, +} + +mod imp { + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/com/lakoliu/Furtherance/gtk/history_box.ui")] + pub struct FurHistoryBox { + // Template widgets + #[template_child] + pub stack: TemplateChild<gtk::Stack>, + #[template_child] + pub spinner: TemplateChild<gtk::Spinner>, + #[template_child] + pub welcome_page: TemplateChild<adw::StatusPage>, + #[template_child] + pub tasks_page: TemplateChild<FurTasksPage>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FurHistoryBox { + const NAME: &'static str = "FurHistoryBox"; + type Type = super::FurHistoryBox; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &subclass::InitializingObject<Self>) { + obj.init_template(); + } + + } + + impl ObjectImpl for FurHistoryBox { + fn constructed(&self, obj: &Self::Type) { + obj.setup_widgets(); + self.parent_constructed(obj); + } + + } + impl WidgetImpl for FurHistoryBox {} + impl BoxImpl for FurHistoryBox {} +} + +glib::wrapper! { + pub struct FurHistoryBox( + ObjectSubclass<imp::FurHistoryBox>) + @extends gtk::Widget, gtk::Box; +} + + +impl FurHistoryBox { + fn setup_widgets(&self) { + self.set_view(View::Loading); + let is_saved_task: bool = match database::check_for_tasks() { + Ok(_) => true, + Err(_) => false, + }; + if is_saved_task { + self.set_view(View::Tasks); + } else { + self.set_view(View::Empty); + } + } + + fn set_view(&self, view: View) { + let imp = imp::FurHistoryBox::from_instance(self); + let app = FurtheranceApplication::default(); + app.delete_enabled(false); + imp.spinner.set_spinning(false); + + let name = match view { + View::Loading => { + imp.spinner.set_spinning(true); + "loading" + } + View::Empty => "empty", + View::Tasks => { + app.delete_enabled(true); + "tasks" + } + }; + + imp.stack.set_visible_child_name(name); + } + + pub fn create_tasks_page(&self) { + let imp = imp::FurHistoryBox::from_instance(self); + imp.tasks_page.clear_task_list(); + let is_saved_task: bool = match database::check_for_tasks() { + Ok(_) => true, + Err(_) => false, + }; + if is_saved_task { + self.set_view(View::Loading); + imp.tasks_page.build_task_list(); + self.set_view(View::Tasks); + } else { + self.set_view(View::Empty); + } + } + + pub fn empty_view(&self) { + self.set_view(View::Empty); + } + +} diff --git a/src/ui/task_details.rs b/src/ui/task_details.rs new file mode 100644 index 0000000..69c3494 --- /dev/null +++ b/src/ui/task_details.rs @@ -0,0 +1,441 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use adw::subclass::prelude::*; +use glib::clone; +use gtk::subclass::prelude::*; +use gtk::{glib, prelude::*, CompositeTemplate}; +use chrono::{DateTime, NaiveDateTime, Local, offset::TimeZone}; + +use crate::FurtheranceApplication; +use crate::ui::FurtheranceWindow; +use crate::database; + +mod imp { + use super::*; + use glib::subclass; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/com/lakoliu/Furtherance/gtk/task_details.ui")] + pub struct FurTaskDetails { + #[template_child] + pub headerbar: TemplateChild<gtk::HeaderBar>, + #[template_child] + pub dialog_title: TemplateChild<adw::WindowTitle>, + #[template_child] + pub scrolled_window: TemplateChild<gtk::ScrolledWindow>, + + #[template_child] + pub task_name_label: TemplateChild<gtk::Label>, + + #[template_child] + pub main_box: TemplateChild<gtk::Box>, + + #[template_child] + pub delete_all_btn: TemplateChild<gtk::Button>, + + pub all_boxes: RefCell<Vec<gtk::Box>>, + pub all_task_ids: RefCell<Vec<i32>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FurTaskDetails { + const NAME: &'static str = "FurTaskDetails"; + type ParentType = adw::Window; + type Type = super::FurTaskDetails; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &subclass::InitializingObject<Self>) { + obj.init_template(); + } + } + + impl ObjectImpl for FurTaskDetails { + + fn constructed(&self, obj: &Self::Type) { + obj.setup_signals(); + obj.setup_delete_all(); + self.parent_constructed(obj); + } + } + + impl WidgetImpl for FurTaskDetails {} + + impl WindowImpl for FurTaskDetails {} + + impl AdwWindowImpl for FurTaskDetails {} +} + +glib::wrapper! { + pub struct FurTaskDetails(ObjectSubclass<imp::FurTaskDetails>) + @extends gtk::Widget, gtk::Window, adw::Window; +} + +impl FurTaskDetails { + pub fn new() -> Self { + let dialog: Self = glib::Object::new(&[]).unwrap(); + + let window = FurtheranceWindow::default(); + dialog.set_transient_for(Some(&window)); + + let app = FurtheranceApplication::default(); + app.add_window(&window); + + dialog + } + + pub fn setup_widgets(&self, mut task_group: Vec<database::Task>) { + let imp = imp::FurTaskDetails::from_instance(self); + + imp.task_name_label.set_text(&task_group[0].task_name); + + for task in task_group.clone() { + imp.all_task_ids.borrow_mut().push(task.id); + } + + let task_group_len = task_group.len(); + task_group.reverse(); + for task in task_group { + let task_box = gtk::Box::new(gtk::Orientation::Horizontal, 5); + task_box.set_homogeneous(true); + + let start_time = DateTime::parse_from_rfc3339(&task.start_time).unwrap(); + let start_time_str = start_time.format("%H:%M:%S").to_string(); + let start = gtk::Button::new(); + start.set_label(&start_time_str); + task_box.append(&start); + + let stop_time = DateTime::parse_from_rfc3339(&task.stop_time).unwrap(); + let stop_time_str = stop_time.format("%H:%M:%S").to_string(); + let stop = gtk::Button::new(); + stop.set_label(&stop_time_str); + task_box.append(&stop); + + let total_time = stop_time - start_time; + let total_time = total_time.num_seconds(); + let h = total_time / 60 / 60; + let m = (total_time / 60) - (h * 60); + let s = total_time - (m * 60); + let total_time_str = format!("{:02}:{:02}:{:02}", h, m, s); + let total = gtk::Button::new(); + total.set_label(&total_time_str); + total.add_css_class("inactive-button"); + total.set_hexpand(false); + task_box.append(&total); + + imp.main_box.append(&task_box); + imp.all_boxes.borrow_mut().push(task_box); + + start.connect_clicked(clone!(@weak self as this => move |_|{ + let window = FurtheranceWindow::default(); + let dialog = gtk::MessageDialog::new( + Some(&window), + gtk::DialogFlags::MODAL, + gtk::MessageType::Question, + gtk::ButtonsType::OkCancel, + "<span size='x-large' weight='bold'>Edit Task</span>", + ); + dialog.set_use_markup(true); + + let message_area = dialog.message_area().downcast::<gtk::Box>().unwrap(); + let vert_box = gtk::Box::new(gtk::Orientation::Vertical, 5); + let task_name_edit = gtk::Entry::new(); + task_name_edit.set_text(&task.task_name); + let labels_box = gtk::Box::new(gtk::Orientation::Horizontal, 5); + labels_box.set_homogeneous(true); + let start_label = gtk::Label::new(Some("Start")); + start_label.add_css_class("title-4"); + let stop_label = gtk::Label::new(Some("Stop")); + stop_label.add_css_class("title-4"); + let times_box = gtk::Box::new(gtk::Orientation::Horizontal, 5); + times_box.set_homogeneous(true); + + let start_time_w_year = start_time.format("%h %e %Y %H:%M:%S").to_string(); + let stop_time_w_year = stop_time.format("%h %e %Y %H:%M:%S").to_string(); + let start_time_edit = gtk::Entry::new(); + start_time_edit.set_text(&start_time_w_year); + let stop_time_edit = gtk::Entry::new(); + stop_time_edit.set_text(&stop_time_w_year); + + let instructions = gtk::Label::new(Some( + "*Use the format MMM DD YYYY HH:MM:SS")); + instructions.set_visible(false); + instructions.add_css_class("error_message"); + + let time_error = gtk::Label::new(Some( + "*Start time cannot be later than stop time.")); + time_error.set_visible(false); + time_error.add_css_class("error_message"); + + let future_error = gtk::Label::new(Some( + "*Time cannot be in the future.")); + future_error.set_visible(false); + future_error.add_css_class("error_message"); + + let delete_task_btn = gtk::Button::new(); + delete_task_btn.set_icon_name("user-trash-symbolic"); + delete_task_btn.set_tooltip_text(Some("Delete task")); + delete_task_btn.set_hexpand(false); + delete_task_btn.set_vexpand(false); + delete_task_btn.set_halign(gtk::Align::End); + + vert_box.append(&task_name_edit); + labels_box.append(&start_label); + labels_box.append(&stop_label); + times_box.append(&start_time_edit); + times_box.append(&stop_time_edit); + vert_box.append(&labels_box); + vert_box.append(×_box); + vert_box.append(&instructions); + vert_box.append(&time_error); + vert_box.append(&future_error); + message_area.append(&delete_task_btn); + message_area.append(&vert_box); + + delete_task_btn.connect_clicked(clone!( + @strong task, @strong dialog, @weak this => move |_| { + + let delete_confirmation = gtk::MessageDialog::with_markup( + Some(&window), + gtk::DialogFlags::MODAL, + gtk::MessageType::Question, + gtk::ButtonsType::OkCancel, + Some("<span size='x-large' weight='bold'>Delete task?</span>"), + ); + + delete_confirmation.connect_response(clone!( + @strong dialog, + @strong delete_confirmation => move |_, resp| { + if resp == gtk::ResponseType::Ok { + let _ = database::delete_by_id(task.id); + if task_group_len == 1 { + delete_confirmation.close(); + dialog.close(); + this.close(); + let window = FurtheranceWindow::default(); + window.reset_history_box(); + } else { + delete_confirmation.close(); + this.clear_task_list(); + dialog.close(); + } + } else { + delete_confirmation.close(); + } + })); + + delete_confirmation.show(); + })); + + + dialog.connect_response( + clone!(@strong dialog, + @strong task.task_name as name + @strong task.start_time as start_time + @strong task.stop_time as stop_time => move |_ , resp| { + if resp == gtk::ResponseType::Ok { + instructions.set_visible(false); + time_error.set_visible(false); + future_error.set_visible(false); + let mut start_successful = false; + let mut stop_successful = false; + let mut do_not_close = false; + let mut new_start_time_edited: String = "".to_string(); + let mut new_start_time_local = Local::now(); + let new_stop_time_edited: String; + if start_time_edit.text() != start_time_w_year { + let new_start_time_str = start_time_edit.text(); + let new_start_time = NaiveDateTime::parse_from_str( + &new_start_time_str, + "%h %e %Y %H:%M:%S"); + if let Err(_) = new_start_time { + instructions.set_visible(true); + do_not_close = true; + } else { + new_start_time_local = Local.from_local_datetime(&new_start_time.unwrap()).unwrap(); + new_start_time_edited = new_start_time_local.to_rfc3339(); + start_successful = true; + } + } + if stop_time_edit.text() != stop_time_w_year { + let new_stop_time_str = stop_time_edit.text(); + let new_stop_time = NaiveDateTime::parse_from_str( + &new_stop_time_str, + "%h %e %Y %H:%M:%S"); + if let Err(_) = new_stop_time { + instructions.set_visible(true); + do_not_close = true; + } else { + let new_stop_time = Local.from_local_datetime(&new_stop_time.unwrap()).unwrap(); + new_stop_time_edited = new_stop_time.to_rfc3339(); + if start_successful { + if (new_stop_time - new_start_time_local).num_seconds() >= 0 { + database::update_stop_time(task.id, new_stop_time_edited.clone()) + .expect("Failed to update stop time."); + database::update_start_time(task.id, new_start_time_edited.clone()) + .expect("Failed to update stop time."); + } + } else { + let old_start_time = DateTime::parse_from_rfc3339(&start_time); + let old_start_time = old_start_time.unwrap().with_timezone(&Local); + if (Local::now() - new_stop_time).num_seconds() < 0 { + future_error.set_visible(true); + do_not_close = true; + } else if (new_stop_time - old_start_time).num_seconds() >= 0 { + database::update_stop_time(task.id, new_stop_time_edited) + .expect("Failed to update stop time."); + } else { + time_error.set_visible(true); + do_not_close = true; + } + } + stop_successful = true; + } + } + if task_name_edit.text() != name { + database::update_task_name(task.id, task_name_edit.text().to_string()) + .expect("Failed to update start time."); + } + + if start_successful && !stop_successful { + let old_stop_time = DateTime::parse_from_rfc3339(&stop_time); + let old_stop_time = old_stop_time.unwrap().with_timezone(&Local); + if (old_stop_time - new_start_time_local).num_seconds() >= 0 { + database::update_start_time(task.id, new_start_time_edited) + .expect("Failed to update start time."); + } else { + time_error.set_visible(true); + do_not_close = true; + } + + } + + if !do_not_close { + this.clear_task_list(); + dialog.close(); + } + + + } else { + // If Cancel, close dialog and do nothing. + dialog.close(); + } + }), + ); + + + dialog.show(); + })); + + stop.connect_clicked(move |_|{ + start.emit_clicked(); + }); + } + } + + fn clear_task_list(&self) { + let imp = imp::FurTaskDetails::from_instance(&self); + + for task_box in &*imp.all_boxes.borrow() { + imp.main_box.remove(task_box); + } + + imp.all_boxes.borrow_mut().clear(); + // Get list from database by a vec of IDs + let updated_list = database::get_list_by_id(imp.all_task_ids.clone().borrow().to_vec()); + imp.all_task_ids.borrow_mut().clear(); + let window = FurtheranceWindow::default(); + window.reset_history_box(); + self.setup_widgets(updated_list.unwrap()); + } + + fn setup_signals(&self) { + let imp = imp::FurTaskDetails::from_instance(self); + + // Add headerbar to dialog when scrolled far + imp.scrolled_window.vadjustment().connect_value_notify( + clone!(@weak self as this => move |adj|{ + let imp = imp::FurTaskDetails::from_instance(&this); + if adj.value() < 120.0 { + imp.headerbar.add_css_class("hidden"); + imp.dialog_title.set_visible(false); + }else { + imp.headerbar.remove_css_class("hidden"); + imp.dialog_title.set_visible(true); + } + }), + ); + + // Make dialog header smaller if the name is long + imp.task_name_label.connect_label_notify(|label| { + let large_title = !(label.text().len() > 25); + + if large_title { + label.remove_css_class("title-2"); + label.add_css_class("title-1"); + } else { + label.remove_css_class("title-1"); + label.add_css_class("title-2"); + } + }); + } + + fn setup_delete_all(&self) { + let imp = imp::FurTaskDetails::from_instance(self); + let window = FurtheranceWindow::default(); + + imp.delete_all_btn.connect_clicked(clone!(@weak self as this => move |_|{ + let dialog = gtk::MessageDialog::with_markup( + Some(&window), + gtk::DialogFlags::MODAL, + gtk::MessageType::Warning, + gtk::ButtonsType::None, + Some("<span size='large'>Delete All?</span>"), + ); + dialog.set_secondary_text(Some("This will delete all occurrences of this task on this day.")); + dialog.add_buttons(&[ + ("Delete", gtk::ResponseType::Accept), + ("Cancel", gtk::ResponseType::Reject) + ]); + dialog.show(); + + dialog.connect_response(clone!(@strong dialog => move |_,resp|{ + if resp == gtk::ResponseType::Accept { + this.delete_all(); + dialog.close(); + this.close(); + let window = FurtheranceWindow::default(); + window.reset_history_box(); + } else { + dialog.close(); + } + })); + + })); + + } + + fn delete_all(&self) { + let imp = imp::FurTaskDetails::from_instance(self); + let _ = database::delete_by_ids(imp.all_task_ids.borrow().to_vec()); + } + +} + diff --git a/src/ui/task_row.rs b/src/ui/task_row.rs new file mode 100644 index 0000000..d17f263 --- /dev/null +++ b/src/ui/task_row.rs @@ -0,0 +1,130 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use gtk::subclass::prelude::*; +use gtk::{glib, gio, prelude::*, CompositeTemplate}; +use chrono::DateTime; +use std::sync::Mutex; +use once_cell::sync::Lazy; +use glib::clone; + +use crate::database::Task; +use crate::ui::FurTaskDetails; + + +mod imp { + use super::*; + use glib::subclass; + + #[derive(Debug, CompositeTemplate, Default)] + #[template(resource = "/com/lakoliu/Furtherance/gtk/task_row.ui")] + pub struct FurTaskRow { + #[template_child] + pub task_name_label: TemplateChild<gtk::Label>, + #[template_child] + pub total_time_label: TemplateChild<gtk::Label>, + // pub tasks: Vec<database::Task>, + pub tasks: Lazy<Mutex<Vec<Task>>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FurTaskRow { + const NAME: &'static str = "FurTaskRow"; + type ParentType = gtk::ListBoxRow; + type Type = super::FurTaskRow; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &subclass::InitializingObject<Self>) { + obj.init_template(); + } + } + + impl ObjectImpl for FurTaskRow { + fn constructed(&self, obj: &Self::Type) { + obj.setup_signals(); + self.parent_constructed(obj); + } + } + + impl WidgetImpl for FurTaskRow {} + + impl ListBoxRowImpl for FurTaskRow {} +} + +glib::wrapper! { + pub struct FurTaskRow( + ObjectSubclass<imp::FurTaskRow>) + @extends gtk::Widget, gtk::ListBoxRow; +} + +impl FurTaskRow { + pub fn new() -> Self { + /* This should take a model, object, or vec filled with the description + details and fill out the labels based on those. */ + glib::Object::new(&[]).expect("Failed to create `FurTaskRow`.") + } + + fn setup_signals(&self) { + let open_details_action = gio::SimpleAction::new("open-details", None); + + open_details_action.connect_activate(clone!(@strong self as this => move |_, _| { + let dialog = FurTaskDetails::new(); + dialog.setup_widgets(this.get_tasks()); + dialog.show(); + })); + + let actions = gio::SimpleActionGroup::new(); + self.insert_action_group("task-row", Some(&actions)); + actions.add_action(&open_details_action); + } + + pub fn set_row_labels(&self, task_list: Vec<Task>) { + let imp = imp::FurTaskRow::from_instance(&self); + for task in task_list.clone() { + imp.tasks.lock().unwrap().push(task); + } + imp.task_name_label.set_text(&imp.tasks.lock().unwrap()[0].task_name); + + // Add up all durations for task of said name to create total_time + let mut total_time: i64 = 0; + for task in &task_list { + if task.task_name == task.task_name { + let start_time = DateTime::parse_from_rfc3339(&task.start_time).unwrap(); + let stop_time = DateTime::parse_from_rfc3339(&task.stop_time).unwrap(); + + let duration = stop_time - start_time; + total_time += duration.num_seconds(); + } + } + // Format total time to readable string + let h = total_time / 60 / 60; + let m = (total_time / 60) - (h * 60); + let s = total_time - (m * 60); + + let total_time_str = format!("{:02}:{:02}:{:02}", h, m, s); + + imp.total_time_label.set_text(&total_time_str); + } + + pub fn get_tasks(&self) -> Vec<Task> { + let imp = imp::FurTaskRow::from_instance(&self); + imp.tasks.lock().unwrap().to_vec() + } +} + diff --git a/src/ui/tasks_group.rs b/src/ui/tasks_group.rs new file mode 100644 index 0000000..86c20dc --- /dev/null +++ b/src/ui/tasks_group.rs @@ -0,0 +1,112 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use adw::subclass::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, prelude::*}; + +use crate::ui::FurTaskRow; +use crate::database; + +mod imp { + use super::*; + use glib::subclass; + use gtk::CompositeTemplate; + + use std::cell::RefCell; + + #[derive(Default, Debug, CompositeTemplate)] + #[template(resource = "/com/lakoliu/Furtherance/gtk/tasks_group.ui")] + pub struct FurTasksGroup { + #[template_child] + pub listbox_box: TemplateChild<gtk::Box>, + pub models: RefCell<Vec<gtk::SortListModel>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FurTasksGroup { + const NAME: &'static str = "FurTasksGroup"; + type ParentType = adw::PreferencesGroup; + type Type = super::FurTasksGroup; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &subclass::InitializingObject<Self>) { + obj.init_template(); + } + } + + impl ObjectImpl for FurTasksGroup {} + impl WidgetImpl for FurTasksGroup {} + impl PreferencesGroupImpl for FurTasksGroup {} +} + +glib::wrapper! { + pub struct FurTasksGroup( + ObjectSubclass<imp::FurTasksGroup>) + @extends gtk::Widget, adw::PreferencesGroup; + +} + +impl FurTasksGroup { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create `FurTaskGroup`.") + } + + pub fn add_task_model(&self, tasks: Vec<database::Task>) { + let imp = imp::FurTasksGroup::from_instance(&self); + + let listbox = gtk::ListBox::new(); + listbox.add_css_class("content"); + listbox.set_selection_mode(gtk::SelectionMode::None); + imp.listbox_box.append(&listbox); + + // Check if tasks have the same name. If they do, make one listbox row for all of them. + // If they don't, move on. + let mut tasks_by_name: Vec<Vec<database::Task>> = Vec::new(); + let mut unique: bool; + + for task in &tasks { + unique = true; + for i in 0..tasks_by_name.len() { + if tasks_by_name[i][0].task_name == task.task_name { + tasks_by_name[i].push(task.clone()); + unique = false; + } + } + if unique { + // Add unique task to list for group name + let mut new_name_list: Vec<database::Task> = Vec::new(); + new_name_list.push(task.clone()); + tasks_by_name.push(new_name_list); + } + } + + for same_name in tasks_by_name { + let listbox_row = FurTaskRow::new(); + listbox_row.set_row_labels(same_name); + listbox.append(&listbox_row); + } + + listbox.connect_row_activated(move |_, row| { + row.activate_action("task-row.open-details", None).unwrap(); + }); + + } +} + diff --git a/src/ui/tasks_page.rs b/src/ui/tasks_page.rs new file mode 100644 index 0000000..fbbaf0c --- /dev/null +++ b/src/ui/tasks_page.rs @@ -0,0 +1,147 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use adw::subclass::prelude::*; +use adw::prelude::{PreferencesPageExt, PreferencesGroupExt}; +use gtk::subclass::prelude::*; +use gtk::{glib, prelude::*}; +use chrono::{DateTime, Local, Duration}; + +use crate::ui::FurTasksGroup; +use crate::database; + +mod imp { + use super::*; + use glib::subclass; + use gtk::CompositeTemplate; + use std::cell::RefCell; + + #[derive(Default, Debug, CompositeTemplate)] + #[template(resource = "/com/lakoliu/Furtherance/gtk/tasks_page.ui")] + pub struct FurTasksPage { + pub all_groups: RefCell<Vec<FurTasksGroup>>, + } + + + #[glib::object_subclass] + impl ObjectSubclass for FurTasksPage { + const NAME: &'static str = "FurTasksPage"; + type ParentType = adw::PreferencesPage; + type Type = super::FurTasksPage; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &subclass::InitializingObject<Self>) { + obj.init_template(); + } + } + + impl ObjectImpl for FurTasksPage { + fn constructed(&self, obj: &Self::Type) { + obj.setup_widgets(); + self.parent_constructed(obj); + } + } + + impl WidgetImpl for FurTasksPage {} + impl PreferencesPageImpl for FurTasksPage {} +} + +glib::wrapper! { + pub struct FurTasksPage( + ObjectSubclass<imp::FurTasksPage>) + @extends gtk::Widget, adw::PreferencesPage; +} + +impl FurTasksPage { + fn setup_widgets(&self) { + self.build_task_list(); + } + + pub fn clear_task_list(&self) { + let imp = imp::FurTasksPage::from_instance(&self); + + for group in &*imp.all_groups.borrow() { + self.remove(group); + } + + imp.all_groups.borrow_mut().clear(); + } + + pub fn build_task_list(&self) { + let imp = imp::FurTasksPage::from_instance(&self); + + let mut tasks_list = database::retrieve().unwrap(); + + // Reversing chronological order of tasks_list + tasks_list.reverse(); + let mut uniq_date_list: Vec<String> = Vec::new(); + let mut same_date_list: Vec<database::Task> = Vec::new(); + let mut tasks_sorted_by_day: Vec<Vec<database::Task>> = Vec::new(); + + // Go through tasks list and look at all dates + let mut i: u32 = 1; + let len = tasks_list.len() as u32; + for task in tasks_list { + let task_clone = task.clone(); + let date = DateTime::parse_from_rfc3339(&task.start_time).unwrap(); + let date = date.format("%h %e").to_string(); + if !uniq_date_list.contains(&date) { + // if same_date_list is empty, push "date" to it + // if it is not empty, push it to a vec of vecs, and then clear it + // and push "date" to it + if same_date_list.is_empty() { + same_date_list.push(task_clone); + } else { + tasks_sorted_by_day.push(same_date_list.clone()); + same_date_list.clear(); + same_date_list.push(task_clone); + } + uniq_date_list.push(date); + } else { + // otherwise push the task to the list of others with the same date + same_date_list.push(task_clone); + } + // If this is the last iteration, push the list of objects to sorted_by_day + if i == len { + tasks_sorted_by_day.push(same_date_list.clone()); + } + i += 1; + } + + // Create FurTasksGroups for all unique days + let now = Local::now(); + let yesterday = now - Duration::days(1); + let yesterday = yesterday.format("%h %e").to_string(); + let today = now.format("%h %e").to_string(); + for i in 0..uniq_date_list.len() { + let group = FurTasksGroup::new(); + if uniq_date_list[i] == today { + group.set_title("Today") + } else if uniq_date_list[i] == yesterday{ + group.set_title("Yesterday") + } else { + group.set_title(&uniq_date_list[i]) + } + self.add(&group); + group.add_task_model(tasks_sorted_by_day[i].clone()); + imp.all_groups.borrow_mut().push(group); + } + } +} + diff --git a/src/ui/window.rs b/src/ui/window.rs new file mode 100644 index 0000000..965f0f3 --- /dev/null +++ b/src/ui/window.rs @@ -0,0 +1,330 @@ +// Furtherance - Track your time without being tracked +// Copyright (C) 2022 Ricky Kresslein <rk@lakoliu.com> +// +// This program 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. +// +// This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +use adw::subclass::prelude::AdwApplicationWindowImpl; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{gio, glib, CompositeTemplate}; +use glib::{clone, timeout_add_local}; +use std::time::Duration; +use std::sync::{Arc, Mutex}; +use std::rc::Rc; +use std::cell::RefCell; +use chrono::{DateTime, Local, Duration as ChronDur}; +use dbus::blocking::Connection; +use once_cell::unsync::OnceCell; + +use crate::ui::FurHistoryBox; +use crate::FurtheranceApplication; +use crate::database; + +mod imp { + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/com/lakoliu/Furtherance/gtk/window.ui")] + pub struct FurtheranceWindow { + // Template widgets + #[template_child] + pub header_bar: TemplateChild<gtk::HeaderBar>, + #[template_child] + pub watch: TemplateChild<gtk::Label>, + #[template_child] + pub task_input: TemplateChild<gtk::Entry>, + #[template_child] + pub start_button: TemplateChild<gtk::Button>, + #[template_child] + pub history_box: TemplateChild<FurHistoryBox>, + #[template_child] + pub toast_overlay: TemplateChild<adw::ToastOverlay>, + + pub notify_of_idle: OnceCell<u64>, + pub stored_idle: Mutex<u64>, + pub idle_notified: Mutex<bool>, + pub idle_time_reached: Mutex<bool>, + pub subtract_idle: Mutex<bool>, + pub idle_start_time: Mutex<String>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FurtheranceWindow { + const NAME: &'static str = "FurtheranceWindow"; + type Type = super::FurtheranceWindow; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + FurHistoryBox::static_type(); + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject<Self>) { + obj.init_template(); + } + } + + impl ObjectImpl for FurtheranceWindow { + fn constructed(&self, obj: &Self::Type) { + obj.setup_signals(); + obj.setup_settings(); + self.parent_constructed(obj); + } + } + impl WidgetImpl for FurtheranceWindow {} + impl WindowImpl for FurtheranceWindow {} + impl ApplicationWindowImpl for FurtheranceWindow {} + impl AdwApplicationWindowImpl for FurtheranceWindow {} +} + +glib::wrapper! { + pub struct FurtheranceWindow(ObjectSubclass<imp::FurtheranceWindow>) + @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl FurtheranceWindow { + pub fn new<P: glib::IsA<gtk::Application>>(application: &P) -> Self { + glib::Object::new(&[("application", application)]) + .expect("Failed to create FurtheranceWindow") + } + + pub fn inapp_notification(&self, text: &str) { + // Display in-app notifications + let imp = imp::FurtheranceWindow::from_instance(self); + let toast = adw::Toast::new(text); + imp.toast_overlay.add_toast(&toast); + } + + fn set_watch_time(&self, text: &str) { + // Update watch time while timer is running + let imp = imp::FurtheranceWindow::from_instance(self); + imp.watch.set_text(text); + self.check_user_idle(); + } + + fn activate_task_input(&self, sensitive: bool) { + // Deactivate task_input while timer is running + let imp = imp::FurtheranceWindow::from_instance(self); + imp.task_input.set_sensitive(sensitive); + } + + fn get_task_text(&self) -> String { + let imp = imp::FurtheranceWindow::from_instance(self); + imp.task_input.text().to_string() + } + + fn save_task(&self, start_time: DateTime<Local>, mut stop_time: DateTime<Local>) { + // Save the most recent task to the database and clear the task_input field + let imp = imp::FurtheranceWindow::from_instance(self); + + if *imp.subtract_idle.lock().unwrap() { + let idle_start = DateTime::parse_from_rfc3339(&imp.idle_start_time.lock().unwrap()).unwrap(); + stop_time = idle_start.with_timezone(&Local); + *imp.subtract_idle.lock().unwrap() = false + } + + let _ = database::db_write(&imp.task_input.text().trim(), start_time, stop_time); + imp.task_input.set_text(""); + imp.history_box.create_tasks_page(); + } + + pub fn reset_history_box(&self) { + let imp = imp::FurtheranceWindow::from_instance(self); + imp.history_box.create_tasks_page(); + } + + fn setup_signals(&self) { + let imp = imp::FurtheranceWindow::from_instance(self); + let running = Arc::new(Mutex::new(false)); + let start_time = Rc::new(RefCell::new(Local::now())); + let stop_time = Rc::new(RefCell::new(Local::now())); + + // Development mode + // self.add_css_class("devel"); + + imp.start_button.connect_clicked(clone!( + @weak self as this, + @strong running => move |button| { + if this.get_task_text().trim().is_empty() { + let dialog = gtk::MessageDialog::with_markup( + Some(&this), + gtk::DialogFlags::MODAL, + gtk::MessageType::Error, + gtk::ButtonsType::Ok, + Some("<span size='large'>No Task Name</span>"), + ); + dialog.set_secondary_text(Some("Enter a task name to start the timer.")); + dialog.show(); + + dialog.connect_response(clone!(@strong dialog => move |_,_|{ + dialog.close(); + })); + + } else { + if !*running.lock().unwrap() { + let mut secs: u32 = 0; + let mut mins: u32 = 0; + let mut hrs: u32 = 0; + + *running.lock().unwrap() = true; + *start_time.borrow_mut() = Local::now(); + this.activate_task_input(false); + let duration = Duration::new(1,0); + timeout_add_local(duration, clone!(@strong running as running_clone => move || { + if *running_clone.lock().unwrap() { + secs += 1; + if secs > 59 { + secs = 0; + mins += 1; + if mins > 59 { + mins = 0; + hrs += 1; + } + } + let watch_text: &str = &format!("{:02}:{:02}:{:02}", hrs, mins, secs).to_string(); + this.set_watch_time(watch_text); + } + Continue(*running_clone.lock().unwrap()) + })); + button.set_icon_name("media-playback-stop-symbolic"); + } else { + *stop_time.borrow_mut() = Local::now(); + *running.lock().unwrap() = false; + button.set_icon_name("media-playback-start-symbolic"); + this.set_watch_time("00:00:00"); + this.activate_task_input(true); + this.save_task(*start_time.borrow(), *stop_time.borrow()); + } + } + })); + } + + fn setup_settings(&self) { + let imp = imp::FurtheranceWindow::from_instance(self); + imp.notify_of_idle.set(300).expect("Failed to set notify_of_idle"); + self.reset_vars(); + + // Enter starts timer + let start = imp.start_button.clone(); + self.set_default_widget(Some(&start)); + imp.task_input.set_activates_default(true); + } + + fn get_idle_time(&self) -> Result<u64, Box<dyn std::error::Error>> { + let c = Connection::new_session()?; + + let p = c.with_proxy("org.gnome.Mutter.IdleMonitor", + "/org/gnome/Mutter/IdleMonitor/Core", + Duration::from_millis(5000) + ); + let (idle_time,): (u64,) = p.method_call("org.gnome.Mutter.IdleMonitor", "GetIdletime", ())?; + + Ok(idle_time / 1000) + } + + fn check_user_idle(&self) { + let imp = imp::FurtheranceWindow::from_instance(self); + // Check for user idle + let idle_time = self.get_idle_time().unwrap(); + + // If user was idle and has now returned... + if idle_time < *imp.notify_of_idle.get().unwrap() + && *imp.idle_time_reached.lock().unwrap() + && !*imp.idle_notified.lock().unwrap() { + + *imp.idle_notified.lock().unwrap() = true; + self.resume_from_idle(); + } + *imp.stored_idle.lock().unwrap() = idle_time; + + // If user is idle but has not returned... + if *imp.stored_idle.lock().unwrap() >= *imp.notify_of_idle.get().unwrap() + && !*imp.idle_time_reached.lock().unwrap() { + + *imp.idle_time_reached.lock().unwrap() = true; + let true_idle_start_time = Local::now() - + ChronDur::seconds(*imp.notify_of_idle.get().unwrap() as i64); + *imp.idle_start_time.lock().unwrap() = true_idle_start_time.to_rfc3339(); + } + } + + fn resume_from_idle(&self) { + let imp = imp::FurtheranceWindow::from_instance(self); + + let resume_time = Local::now(); + let idle_start = DateTime::parse_from_rfc3339(&imp.idle_start_time.lock().unwrap()).unwrap(); + let idle_start = idle_start.with_timezone(&Local); + let idle_time = resume_time - idle_start; + let idle_time = idle_time.num_seconds(); + let h = idle_time / 60 / 60; + let m = (idle_time / 60) - (h * 60); + let s = idle_time - (m * 60); + let idle_time_str = format!( + "You have been idle for {:02}:{:02}:{:02}.\nWould you like to discard that time, or continue the clock?", + h, m, s); + + let dialog = gtk::MessageDialog::with_markup( + Some(self), + gtk::DialogFlags::MODAL, + gtk::MessageType::Warning, + gtk::ButtonsType::None, + Some("<span size='x-large' weight='bold'>Edit Task</span>"), + ); + dialog.add_buttons(&[ + ("Discard", gtk::ResponseType::Reject), + ("Continue", gtk::ResponseType::Accept) + ]); + dialog.set_secondary_text(Some(&idle_time_str)); + + dialog.connect_response(clone!( + @weak self as this, + @strong dialog, + @strong imp.start_button as start_button => move |_, resp| { + if resp == gtk::ResponseType::Reject { + this.set_subtract_idle(true); + start_button.emit_clicked(); + dialog.close(); + } else { + this.reset_vars(); + dialog.close(); + } + })); + + dialog.show() + } + + fn reset_vars(&self) { + let imp = imp::FurtheranceWindow::from_instance(self); + *imp.stored_idle.lock().unwrap() = 0; + *imp.idle_notified.lock().unwrap() = false; + *imp.idle_time_reached.lock().unwrap() = false; + *imp.subtract_idle.lock().unwrap() = false; + } + + fn set_subtract_idle(&self, val: bool) { + let imp = imp::FurtheranceWindow::from_instance(self); + *imp.subtract_idle.lock().unwrap() = val; + } +} + +impl Default for FurtheranceWindow { + fn default() -> Self { + FurtheranceApplication::default() + .active_window() + .unwrap() + .downcast() + .unwrap() + } +} |