// Furtherance - Track your time without being tracked // Copyright (C) 2022 Ricky Kresslein // // 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 . use adw::subclass::prelude::AdwApplicationWindowImpl; use gettextrs::*; 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::Mutex; use std::rc::Rc; use std::cell::RefCell; use chrono::{DateTime, Local, Duration as ChronDur}; use dbus::blocking::Connection; use itertools::Itertools; use crate::ui::FurHistoryBox; use crate::FurtheranceApplication; use crate::database; use crate::settings_manager; use crate::config; 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, #[template_child] pub watch: TemplateChild, #[template_child] pub task_input: TemplateChild, #[template_child] pub start_button: TemplateChild, #[template_child] pub history_box: TemplateChild, #[template_child] pub toast_overlay: TemplateChild, pub stored_idle: Mutex, pub idle_notified: Mutex, pub idle_time_reached: Mutex, pub subtract_idle: Mutex, pub idle_start_time: Mutex, pub running: Mutex, pub pomodoro_continue: Mutex, pub idle_dialog: Mutex, } #[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) { obj.init_template(); } } impl ObjectImpl for FurtheranceWindow { fn constructed(&self, obj: &Self::Type) { obj.setup_widgets(); 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) @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, @implements gio::ActionGroup, gio::ActionMap; } impl FurtheranceWindow { pub fn new>(application: &P) -> Self { glib::Object::new(&[("application", application)]) .expect("Failed to create FurtheranceWindow") } pub fn display_toast(&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); if settings_manager::get_bool("notify-of-idle") { self.check_user_idle(); } } pub fn save_task(&self, start_time: DateTime, mut stop_time: DateTime) { // 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 task_input_text = imp.task_input.text(); let mut split_tags: Vec<&str> = task_input_text.trim().split("#").collect(); // Remove task name from tags list let task_name = *split_tags.first().unwrap(); split_tags.remove(0); // Trim whitespace around each tag split_tags = split_tags.iter().map(|x| x.trim()).collect(); // Don't allow empty tags split_tags.retain(|&x| !x.trim().is_empty()); // Handle duplicate tags before they are ever saved split_tags = split_tags.into_iter().unique().collect(); // Lowercase tags let lower_tags: Vec = split_tags.iter().map(|x| x.to_lowercase()).collect(); let tag_list = lower_tags.join(" #"); let _ = database::db_write(task_name.trim(), start_time, stop_time, tag_list); imp.task_input.set_text(""); imp.history_box.create_tasks_page(); self.reset_idle(); } pub fn reset_history_box(&self) { let imp = imp::FurtheranceWindow::from_instance(self); imp.history_box.create_tasks_page(); } fn setup_widgets(&self) { let imp = imp::FurtheranceWindow::from_instance(self); // Set initial minimum height let is_saved_task: bool = match database::check_for_tasks() { Ok(_) => true, Err(_) => false, }; if is_saved_task { self.set_height_request(300); } else { self.set_height_request(390); } // Development mode if config::PROFILE == "development" { self.add_css_class("devel"); } *imp.pomodoro_continue.lock().unwrap() = false; imp.start_button.set_sensitive(false); imp.start_button.add_css_class("suggested-action"); self.refresh_timer(); imp.task_input.grab_focus(); } fn setup_signals(&self) { let imp = imp::FurtheranceWindow::from_instance(self); *imp.running.lock().unwrap() = false; let start_time = Rc::new(RefCell::new(Local::now())); let stop_time = Rc::new(RefCell::new(Local::now())); imp.task_input.connect_changed(clone!(@weak self as this => move |task_input| { let imp2 = imp::FurtheranceWindow::from_instance(&this); let task_input_text = task_input.text(); let split_tags: Vec<&str> = task_input_text.trim().split("#").collect(); if split_tags[0].trim().is_empty() { imp2.start_button.set_sensitive(false); } else { imp2.start_button.set_sensitive(true); } })); imp.start_button.connect_clicked(clone!(@weak self as this => move |button| { let imp2 = imp::FurtheranceWindow::from_instance(&this); if !*imp2.running.lock().unwrap() { if settings_manager::get_bool("pomodoro") && !*imp2.pomodoro_continue.lock().unwrap() { let mut secs: i32 = 0; let mut mins: i32 = settings_manager::get_int("pomodoro-time"); let mut hrs: i32 = mins / 60; mins = mins % 60; *imp2.running.lock().unwrap() = true; *start_time.borrow_mut() = Local::now(); let timer_start = *start_time.borrow(); imp2.task_input.set_sensitive(false); let duration = Duration::new(1,0); timeout_add_local(duration, clone!(@strong this as this_clone => move || { let imp3 = imp::FurtheranceWindow::from_instance(&this_clone); if *imp3.running.lock().unwrap() { secs -= 1; if secs < 0 { secs = 59; mins -= 1; if mins < 0 { mins = 59; hrs -= 1; } } let watch_text: &str = &format!("{:02}:{:02}:{:02}", hrs, mins, secs).to_string(); this_clone.set_watch_time(watch_text); } if hrs == 0 && mins == 0 && secs == 0 { let timer_stop = Local::now(); *imp3.running.lock().unwrap() = false; this_clone.pomodoro_over(timer_start, timer_stop); } Continue(*imp3.running.lock().unwrap()) })); } else { let mut secs: u32 = 0; let mut mins: u32 = 0; let mut hrs: u32 = 0; if *imp2.pomodoro_continue.lock().unwrap() { let pomodoro_start_time = *start_time.borrow(); let now_time = Local::now(); let continue_time = now_time - pomodoro_start_time; let continue_time = continue_time.num_seconds() as u32; hrs = continue_time / 3600; mins = continue_time % 3600 / 60; secs = continue_time % 60; let watch_text: &str = &format!("{:02}:{:02}:{:02}", hrs, mins, secs).to_string(); this.set_watch_time(watch_text); *imp2.pomodoro_continue.lock().unwrap() = false; } else { *start_time.borrow_mut() = Local::now(); } *imp2.running.lock().unwrap() = true; imp2.task_input.set_sensitive(false); let duration = Duration::new(1,0); timeout_add_local(duration, clone!(@strong this as this_clone => move || { let imp3 = imp::FurtheranceWindow::from_instance(&this_clone); if *imp3.running.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_clone.set_watch_time(watch_text); } Continue(*imp3.running.lock().unwrap()) })); } button.set_icon_name("media-playback-stop-symbolic"); } else { *stop_time.borrow_mut() = Local::now(); *imp2.running.lock().unwrap() = false; button.set_icon_name("media-playback-start-symbolic"); this.refresh_timer(); imp2.task_input.set_sensitive(true); this.save_task(*start_time.borrow(), *stop_time.borrow()); } })); } fn setup_settings(&self) { let imp = imp::FurtheranceWindow::from_instance(self); self.reset_idle(); // 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> { 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 = match self.get_idle_time() { Ok(val) => val, Err(_) => 1, }; // If user was idle and has now returned... if idle_time < (settings_manager::get_int("idle-time") * 60) as u64 && *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() >= (settings_manager::get_int("idle-time") * 60) as u64 && !*imp.idle_time_reached.lock().unwrap() { *imp.idle_time_reached.lock().unwrap() = true; let true_idle_start_time = Local::now() - ChronDur::seconds((settings_manager::get_int("idle-time") * 60) 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!("{}{:02}:{:02}:{:02}", gettext("You have been idle for "), h, m, s); let question_str = gettext("\nWould you like to discard that time, or continue the clock?"); let idle_time_msg = format!("{}{}", idle_time_str, question_str); let dialog = gtk::MessageDialog::with_markup( Some(self), gtk::DialogFlags::MODAL, gtk::MessageType::Warning, gtk::ButtonsType::None, Some(&format!("{}", &gettext("Idle"))), ); dialog.add_buttons(&[ (&gettext("Discard"), gtk::ResponseType::Reject), (&gettext("Continue"), gtk::ResponseType::Accept) ]); dialog.set_secondary_text(Some(&idle_time_msg)); 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 if resp == gtk::ResponseType::Accept { this.reset_idle(); dialog.close(); } })); *imp.idle_dialog.lock().unwrap() = dialog.clone(); let app = FurtheranceApplication::default(); app.system_idle_notification(&idle_time_str, &question_str); dialog.show(); } fn pomodoro_over(&self, timer_start: DateTime, timer_stop: DateTime) { let dialog = gtk::MessageDialog::with_markup( Some(self), gtk::DialogFlags::MODAL, gtk::MessageType::Warning, gtk::ButtonsType::None, Some(&format!("{}", &gettext("Time's up!"))), ); dialog.add_buttons(&[ (&gettext("Continue"), gtk::ResponseType::Accept), (&gettext("Stop"), gtk::ResponseType::Reject) ]); let app = FurtheranceApplication::default(); app.system_pomodoro_notification(dialog.clone()); dialog.connect_response(clone!( @weak self as this, @strong dialog => move |_, resp| { let imp = imp::FurtheranceWindow::from_instance(&this); if resp == gtk::ResponseType::Reject { imp.start_button.set_icon_name("media-playback-start-symbolic"); this.refresh_timer(); imp.task_input.set_sensitive(true); this.save_task(timer_start, timer_stop); this.reset_idle(); dialog.close(); } else if resp == gtk::ResponseType::Accept { *imp.pomodoro_continue.lock().unwrap() = true; this.reset_idle(); imp.start_button.emit_clicked(); dialog.close(); } })); let imp2 = imp::FurtheranceWindow::from_instance(self); imp2.idle_dialog.lock().unwrap().close(); dialog.show(); } pub fn reset_idle(&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; } pub fn set_subtract_idle(&self, val: bool) { let imp = imp::FurtheranceWindow::from_instance(self); *imp.subtract_idle.lock().unwrap() = val; } pub fn duplicate_task(&self, task: database::Task) { let imp = imp::FurtheranceWindow::from_instance(self); if !*imp.running.lock().unwrap() { let task_text: String; if task.tags.trim().is_empty() { task_text = task.task_name; } else { task_text = format!("{} #{}", task.task_name, task.tags); } imp.task_input.set_text(&task_text); imp.start_button.emit_clicked(); } else { self.display_toast(&gettext("Stop the timer to duplicate a task.")); } } pub fn refresh_timer (&self) { let imp = imp::FurtheranceWindow::from_instance(self); if settings_manager::get_bool("pomodoro") { let mut mins = settings_manager::get_int("pomodoro-time"); let mut hrs: i32 = 0; if mins > 59 { hrs = mins / 60; mins = mins % 60; } let watch_text: &str = &format!("{:02}:{:02}:00", hrs, mins); imp.watch.set_text(watch_text); } else { imp.watch.set_text("00:00:00"); } } } impl Default for FurtheranceWindow { fn default() -> Self { FurtheranceApplication::default() .active_window() .unwrap() .downcast() .unwrap() } }