diff options
Diffstat (limited to 'src/ui')
-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 |
6 files changed, 1298 insertions, 0 deletions
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() + } +} |