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 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
91 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 self.selected_task = None;
271 } else {
272 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 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 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 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 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 egui::Grid::new("task-grid")
340 .num_columns(columns)
341 .show(ui, |ui| {
342 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 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 ctx.request_repaint_after(Duration::from_secs(self.settings.refresh_rate_seconds));
542 }
543}
544
545impl eframe::App for TaskPickerApp {
546 fn save(&mut self, storage: &mut dyn eframe::Storage) {
548 eframe::set_value(storage, eframe::APP_KEY, self);
549 }
550
551 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 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;