task_picker/
app.rs

1use std::time::{Duration, Instant};
2
3#[double]
4use crate::tasks::TaskManager;
5use crate::{
6    sources::{
7        CalDavSource, GitHubSource, GitLabSource, OpenProjectSource, TaskSource, CALDAV_ICON,
8        GITHUB_ICON, GITLAB_ICON, OPENPROJECT_ICON,
9    },
10    tasks::Task,
11};
12use chrono::prelude::*;
13use eframe::epaint::ahash::HashSet;
14use egui::{
15    Color32, Context, Layout, RichText, ScrollArea, Slider, Style, TextEdit, Ui, Vec2, Visuals,
16};
17use egui_notify::{Toast, Toasts};
18use ellipse::Ellipse;
19use itertools::Itertools;
20use log::error;
21use minicaldav::Error;
22use mockall_double::double;
23use ureq::ErrorKind;
24
25const BOX_WIDTH: f32 = 220.0;
26
27#[derive(serde::Deserialize, serde::Serialize)]
28#[serde(default)]
29struct Settings {
30    refresh_rate_seconds: u64,
31    dark_mode: bool,
32}
33
34impl Default for Settings {
35    fn default() -> Self {
36        Self {
37            refresh_rate_seconds: 15,
38            dark_mode: false,
39        }
40    }
41}
42
43#[derive(serde::Deserialize, serde::Serialize)]
44#[serde(default)]
45pub struct TaskPickerApp {
46    task_manager: TaskManager,
47    selected_task: Option<String>,
48    settings: Settings,
49    #[serde(skip)]
50    last_refreshed: Instant,
51    #[serde(skip)]
52    messages: Toasts,
53    #[serde(skip)]
54    edit_source: Option<TaskSource>,
55    #[serde(skip)]
56    currently_edited_secret: String,
57    #[serde(skip)]
58    existing_edit_source: bool,
59    #[serde(skip)]
60    connection_error_for_source: HashSet<String>,
61    #[serde(skip)]
62    overwrite_current_time: Option<DateTime<Utc>>,
63    #[serde(skip)]
64    app_version: String,
65}
66
67impl Default for TaskPickerApp {
68    fn default() -> Self {
69        let settings = Settings::default();
70        Self {
71            task_manager: TaskManager::default(),
72            selected_task: None,
73            last_refreshed: Instant::now()
74                .checked_sub(Duration::from_secs(settings.refresh_rate_seconds))
75                .unwrap_or_else(Instant::now),
76            settings,
77            edit_source: None,
78            currently_edited_secret: String::default(),
79            messages: Toasts::default(),
80            existing_edit_source: false,
81            connection_error_for_source: HashSet::default(),
82            overwrite_current_time: None,
83            app_version: env!("CARGO_PKG_VERSION").to_string(),
84        }
85    }
86}
87
88impl TaskPickerApp {
89    /// Called once before the first frame.
90    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
91        // This is also where you can customize the look and feel of egui using
92        // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
93
94        // Load previous app state (if any).
95        // Note that you must enable the `persistence` feature for this to work.
96        let app = if let Some(storage) = cc.storage {
97            eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default()
98        } else {
99            TaskPickerApp::default()
100        };
101
102        app.init_with_egui_context(&cc.egui_ctx);
103
104        app
105    }
106
107    pub fn init_with_egui_context(&self, ctx: &egui::Context) {
108        if self.settings.dark_mode {
109            ctx.set_visuals(Visuals::dark());
110        } else {
111            ctx.set_visuals(Visuals::light());
112        }
113
114        let mut fonts = egui::epaint::text::FontDefinitions::default();
115        egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
116        ctx.set_fonts(fonts);
117    }
118
119    fn edit_source(&mut self, ctx: &egui::Context) {
120        let window_title = if let Some(source) = &self.edit_source {
121            format!("{} source", source.type_name())
122        } else {
123            "Source".to_string()
124        };
125        egui::Window::new(window_title).show(ctx, |ui| {
126            if let Some(source) = &mut self.edit_source {
127                match source {
128                    TaskSource::CalDav(source) => {
129                        ui.horizontal(|ui| {
130                            ui.label("Calendar Name");
131                            if self.existing_edit_source {
132                                ui.label(&source.calendar_name);
133                            } else {
134                                ui.text_edit_singleline(&mut source.calendar_name);
135                            }
136                        });
137                        ui.horizontal(|ui| {
138                            ui.label("Base Url");
139                            ui.text_edit_singleline(&mut source.base_url);
140                        });
141
142                        ui.horizontal(|ui| {
143                            ui.label("User Name");
144                            ui.text_edit_singleline(&mut source.username);
145                        });
146                        ui.horizontal(|ui| {
147                            ui.label("Password");
148                            ui.add(
149                                TextEdit::singleline(&mut self.currently_edited_secret)
150                                    .password(true),
151                            );
152                        });
153                    }
154                    TaskSource::GitHub(source) => {
155                        ui.horizontal(|ui| {
156                            ui.label("Name");
157                            if self.existing_edit_source {
158                                ui.label(&source.name);
159                            } else {
160                                ui.text_edit_singleline(&mut source.name);
161                            }
162                        });
163                        ui.horizontal(|ui| {
164                            ui.label("Server URL");
165                            ui.text_edit_singleline(&mut source.server_url);
166                        });
167                        ui.horizontal(|ui| {
168                            ui.label("API Token");
169                            ui.add(
170                                TextEdit::singleline(&mut self.currently_edited_secret)
171                                    .password(true),
172                            );
173                        });
174                    }
175                    TaskSource::GitLab(source) => {
176                        ui.horizontal(|ui| {
177                            ui.label("Name");
178                            if self.existing_edit_source {
179                                ui.label(&source.name);
180                            } else {
181                                ui.text_edit_singleline(&mut source.name);
182                            }
183                        });
184                        ui.horizontal(|ui| {
185                            ui.label("Server URL");
186                            ui.text_edit_singleline(&mut source.server_url);
187                        });
188                        ui.horizontal(|ui| {
189                            ui.label("User ID");
190                            ui.text_edit_singleline(&mut source.user_name);
191                        });
192                        ui.horizontal(|ui| {
193                            ui.label("API Token");
194                            ui.add(
195                                TextEdit::singleline(&mut self.currently_edited_secret)
196                                    .password(true),
197                            );
198                        });
199                    }
200                    TaskSource::OpenProject(source) => {
201                        ui.horizontal(|ui| {
202                            ui.label("Name");
203                            if self.existing_edit_source {
204                                ui.label(&source.name);
205                            } else {
206                                ui.text_edit_singleline(&mut source.name);
207                            }
208                        });
209                        ui.horizontal(|ui| {
210                            ui.label("Server URL");
211                            ui.text_edit_singleline(&mut source.server_url);
212                        });
213
214                        ui.horizontal(|ui| {
215                            ui.label("API Token");
216                            ui.add(
217                                TextEdit::singleline(&mut self.currently_edited_secret)
218                                    .password(true),
219                            );
220                        });
221                    }
222                }
223                ui.horizontal(|ui| {
224                    if ui.button("Save").clicked() {
225                        if let Some(source) = &self.edit_source {
226                            self.task_manager.add_or_replace_source(
227                                source.clone(),
228                                &self.currently_edited_secret,
229                            );
230                        }
231                        self.edit_source = None;
232                        self.currently_edited_secret.clear();
233                        self.trigger_refresh(true, ctx.clone());
234                    }
235                    if ui.button("Discard").clicked() {
236                        self.edit_source = None;
237                        self.currently_edited_secret.clear();
238                    }
239                });
240            }
241        });
242    }
243
244    fn render_single_task(&mut self, ui: &mut Ui, task: Task, now: DateTime<Utc>) {
245        let mut group = egui::Frame::group(ui.style());
246        let overdue = task.due.filter(|d| d.cmp(&now).is_le()).is_some();
247        if Some(task.get_id()) == self.selected_task {
248            group.fill = ui.visuals().selection.bg_fill;
249        } else if overdue {
250            group.fill = ui.visuals().error_fg_color;
251        }
252        group.show(ui, |ui| {
253            let size = Vec2::new(BOX_WIDTH, 250.0);
254            ui.set_min_size(size);
255            ui.set_max_size(size);
256            ui.style_mut().wrap = Some(true);
257
258            ui.vertical(|ui| {
259                let task_is_selected = Some(task.get_id()) == self.selected_task;
260
261                ui.vertical_centered_justified(|ui| {
262                    let caption = if task_is_selected {
263                        "Deselect"
264                    } else {
265                        "Select"
266                    };
267                    if ui.button(caption).clicked() {
268                        if Some(task.get_id()) == self.selected_task {
269                            // Already selected, deselect
270                            self.selected_task = None;
271                        } else {
272                            // Select this task
273                            self.selected_task = Some(task.get_id());
274                        }
275                    }
276                });
277                if !task_is_selected {
278                    if overdue {
279                        ui.visuals_mut().override_text_color = Some(Color32::WHITE);
280                    } else if self.selected_task.is_some() {
281                        // Another task is selected. Make this task less visible
282                        ui.visuals_mut().override_text_color = Some(ui.visuals().weak_text_color());
283                    }
284                }
285                ui.heading(task.title.as_str().truncate_ellipse(80));
286                ui.label(egui::RichText::new(task.project));
287
288                if let Some(due_utc) = &task.due {
289                    // Convert to local time for display
290                    let due_local: DateTime<Local> = due_utc.with_timezone(&Local);
291                    let mut due_label =
292                        RichText::new(format!("Due: {}", due_local.format("%a, %d %b %Y %H:%M")));
293                    if !overdue {
294                        // Mark the number of days with the color.
295                        // If the task is overdue, the background
296                        // already be red, an no further highlight
297                        // is necessary.
298                        let hours_to_finish = due_utc.signed_duration_since(now).num_hours();
299                        if hours_to_finish < 24 {
300                            due_label = due_label.color(ui.visuals().error_fg_color);
301                        } else if hours_to_finish < 48 {
302                            due_label = due_label.color(ui.visuals().warn_fg_color);
303                        };
304                    }
305                    ui.label(due_label);
306                }
307                if let Some(created) = &task.created {
308                    ui.label(format!("Created: {}", created.format("%a, %d %b %Y %H:%M")));
309                }
310                ui.separator();
311                if task.description.starts_with("https://") {
312                    ui.hyperlink_to(
313                        task.description.as_str().truncate_ellipse(100),
314                        task.description.as_str(),
315                    );
316                } else {
317                    ui.label(task.description.as_str().truncate_ellipse(100));
318                }
319            });
320        });
321    }
322
323    fn render_all_tasks(&mut self, all_tasks: Vec<Task>, ui: &mut Ui) {
324        let now = self.overwrite_current_time.unwrap_or_else(Utc::now);
325
326        let box_width_with_spacing = BOX_WIDTH + (2.0 * ui.style().spacing.item_spacing.x);
327        let ratio = (ui.available_width() - 5.0) / (box_width_with_spacing);
328        let columns = (ratio.floor() as usize).max(1);
329
330        // Check that the selection is valid and unselect if the task does not
331        // exists
332        if let Some(selection) = self.selected_task.clone() {
333            if !all_tasks.iter().any(|t| t.get_id() == selection) {
334                self.selected_task = None;
335            }
336        }
337
338        // Create a grid layout where each row can show up to 5 tasks
339        egui::Grid::new("task-grid")
340            .num_columns(columns)
341            .show(ui, |ui| {
342                // Get all tasks for all active source
343                let mut task_counter = 0;
344                for task in all_tasks {
345                    self.render_single_task(ui, task, now);
346                    task_counter += 1;
347                    if task_counter % columns == 0 {
348                        ui.end_row();
349                    }
350                }
351            });
352    }
353
354    fn trigger_refresh(&mut self, manually_triggered: bool, ctx: Context) {
355        self.last_refreshed = Instant::now();
356        self.connection_error_for_source.clear();
357
358        self.task_manager.refresh(move || {
359            ctx.request_repaint();
360        });
361
362        if manually_triggered {
363            let mut msg = Toast::info("Refreshing task list in the background");
364            msg.set_duration(Some(Duration::from_secs(1)));
365            self.messages.add(msg);
366        }
367    }
368
369    pub fn render(&mut self, ctx: &egui::Context) {
370        egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
371            ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| {
372                ui.label(format!("Version {}", self.app_version));
373                ui.separator();
374
375                let style: Style = (*ui.ctx().style()).clone();
376                let new_visuals = style.visuals.light_dark_small_toggle_button(ui);
377                if let Some(visuals) = new_visuals {
378                    self.settings.dark_mode = visuals.dark_mode;
379                    ui.ctx().set_visuals(visuals);
380                }
381
382                ui.separator();
383
384                ui.add(
385                    Slider::new(&mut self.settings.refresh_rate_seconds, 5..=120)
386                        .text("Refresh Rate (seconds)"),
387                );
388            });
389        });
390
391        egui::SidePanel::left("side_panel")
392            .resizable(true)
393            .show(ctx, |ui| {
394                ui.heading("Sources");
395
396                let mut remove_source = None;
397                let mut edit_source = None;
398                let mut refresh = false;
399
400                for i in 0..self.task_manager.sources().len() {
401                    let (s, enabled) = &mut self.task_manager.source_ref_mut(i);
402                    let source_name = s.name();
403                    let source_icon = s.icon();
404
405                    ui.horizontal(|ui| {
406                        let source_checkbox = ui.checkbox(
407                            enabled,
408                            egui::RichText::new(format!("{source_icon} {source_name}")),
409                        );
410                        if source_checkbox.changed() {
411                            refresh = true;
412                        }
413                        source_checkbox.context_menu(|ui| {
414                            if ui.button("Edit").clicked() {
415                                edit_source = Some(i);
416                                refresh = true;
417                                ui.close_menu();
418                            }
419                            if ui.button("Remove").clicked() {
420                                remove_source = Some(i);
421                                refresh = true;
422                                ui.close_menu();
423                            }
424                        });
425                        if self.connection_error_for_source.contains(source_name) {
426                            ui.label("📵").on_hover_ui(|ui| {
427                                ui.label("Not Connected");
428                            });
429                        }
430                    });
431                }
432                if let Some(i) = remove_source {
433                    self.task_manager.remove_source(i);
434                } else if let Some(i) = edit_source {
435                    let source = self.task_manager.sources()[i].0.clone();
436                    self.currently_edited_secret = source.secret().unwrap_or_default();
437                    self.edit_source = Some(source);
438                    self.existing_edit_source = true;
439                }
440
441                if refresh {
442                    self.trigger_refresh(true, ctx.clone());
443                }
444
445                ui.separator();
446                ui.label("Add source");
447
448                ui.horizontal_wrapped(|ui| {
449                    if ui
450                        .button(egui::RichText::new(format!("{} CalDAV", CALDAV_ICON)))
451                        .clicked()
452                    {
453                        self.existing_edit_source = false;
454                        self.edit_source = Some(TaskSource::CalDav(CalDavSource::default()));
455                    }
456                    if ui
457                        .button(egui::RichText::new(format!("{} GitHub", GITHUB_ICON)))
458                        .clicked()
459                    {
460                        self.existing_edit_source = false;
461                        self.edit_source = Some(TaskSource::GitHub(GitHubSource::default()));
462                    }
463                    if ui
464                        .button(egui::RichText::new(format!("{} GitLab", GITLAB_ICON)))
465                        .clicked()
466                    {
467                        self.existing_edit_source = false;
468                        self.edit_source = Some(TaskSource::GitLab(GitLabSource::default()));
469                    }
470                    if ui
471                        .button(egui::RichText::new(format!(
472                            "{} OpenProject",
473                            OPENPROJECT_ICON
474                        )))
475                        .clicked()
476                    {
477                        self.existing_edit_source = false;
478                        self.edit_source =
479                            Some(TaskSource::OpenProject(OpenProjectSource::default()));
480                    }
481                });
482            });
483
484        egui::CentralPanel::default().show(ctx, |ui| {
485            self.messages.show(ctx);
486
487            ui.horizontal(|ui| {
488                ui.heading("Tasks");
489
490                if ui
491                    .button(egui::RichText::new(format!(
492                        "{} Refresh",
493                        egui_phosphor::regular::ARROWS_CLOCKWISE
494                    )))
495                    .clicked()
496                {
497                    self.trigger_refresh(true, ctx.clone());
498                }
499            });
500            ScrollArea::vertical().show(ui, |ui| {
501                self.render_all_tasks(self.task_manager.tasks(), ui)
502            });
503        });
504
505        if self.edit_source.is_some() {
506            self.edit_source(ctx);
507        } else if self
508            .last_refreshed
509            .elapsed()
510            .cmp(&Duration::from_secs(self.settings.refresh_rate_seconds))
511            .is_gt()
512        {
513            self.trigger_refresh(false, ctx.clone());
514        }
515
516        for (source, active) in self.task_manager.sources() {
517            if *active {
518                let source_name = source.name();
519                if let Some(err) = self.task_manager.get_and_clear_last_err(source.name()) {
520                    if is_dns_error(&err) {
521                        // DNS errors indicate a connection problem on our side
522                        self.connection_error_for_source
523                            .insert(source_name.to_string());
524                    } else {
525                        error!("Error querying source \"{source_name}\". {}", &err);
526                        let shortened_message = err
527                            .to_string()
528                            .chars()
529                            .chunks(50)
530                            .into_iter()
531                            .map(|c| c.collect::<String>())
532                            .join("\n");
533                        self.messages
534                            .error(format!("[{source_name}]\n{shortened_message}"));
535                    }
536                }
537            }
538        }
539
540        // Make sure we will be called after the refresh rate has been expired
541        ctx.request_repaint_after(Duration::from_secs(self.settings.refresh_rate_seconds));
542    }
543}
544
545impl eframe::App for TaskPickerApp {
546    /// Called by the frame work to save state before shutdown.
547    fn save(&mut self, storage: &mut dyn eframe::Storage) {
548        eframe::set_value(storage, eframe::APP_KEY, self);
549    }
550
551    /// Called each time the UI needs repainting, which may be many times per second.
552    /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
553    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
554        self.render(ctx);
555    }
556}
557
558fn is_dns_error(err: &anyhow::Error) -> bool {
559    if let Some(Error::Ical(caldav_err)) = err.downcast_ref::<minicaldav::Error>() {
560        // The errors only transport the string, so we have to search the error
561        // message for a matching string
562        caldav_err.starts_with("Transport(Transport { kind: Dns,")
563    } else if let Some(transport_err) = err.downcast_ref::<ureq::Error>() {
564        ErrorKind::Dns == transport_err.kind()
565    } else {
566        false
567    }
568}
569
570#[cfg(test)]
571mod tests;