2026-06-20 19:51:10 +01:00
|
|
|
use std::sync::mpsc as std_mpsc;
|
2026-06-20 19:41:47 +01:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
use chrono::NaiveDateTime;
|
2026-06-21 15:45:14 +01:00
|
|
|
use chrono::NaiveTime;
|
2026-06-20 19:41:47 +01:00
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
|
use tokio::sync::mpsc;
|
2026-06-20 19:38:12 +01:00
|
|
|
|
|
|
|
|
use crate::domain::models::*;
|
2026-06-20 19:41:47 +01:00
|
|
|
use crate::infrastructure::api::ApiClient;
|
2026-06-20 19:38:12 +01:00
|
|
|
use crate::infrastructure::db::Db;
|
|
|
|
|
use crate::ui::{Focus, NetworkStatus, Popup};
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
pub struct SyncStats {
|
|
|
|
|
pub last_sync_time: Option<chrono::NaiveDateTime>,
|
|
|
|
|
pub last_pull_time: Option<chrono::NaiveDateTime>,
|
|
|
|
|
pub lists_changed: usize,
|
|
|
|
|
pub tasks_changed: usize,
|
|
|
|
|
pub version: u64,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
pub struct App {
|
|
|
|
|
pub lists: Vec<TaskList>,
|
|
|
|
|
pub tasks: Vec<Task>,
|
|
|
|
|
pub selected_list: usize,
|
|
|
|
|
pub selected_task: usize,
|
|
|
|
|
pub focus: Focus,
|
|
|
|
|
pub show_popup: Option<Popup>,
|
|
|
|
|
pub network_status: NetworkStatus,
|
|
|
|
|
pub popup_input: String,
|
|
|
|
|
pub popup_cursor: usize,
|
2026-06-21 15:45:14 +01:00
|
|
|
pub popup_secondary: String,
|
|
|
|
|
pub popup_secondary_cursor: usize,
|
|
|
|
|
pub edit_field: usize,
|
2026-06-20 19:38:12 +01:00
|
|
|
pub draft_date: NaiveDateTime,
|
|
|
|
|
pub should_quit: bool,
|
|
|
|
|
pub task_list_scroll: u16,
|
|
|
|
|
pub detail_scroll: u16,
|
2026-06-20 19:41:47 +01:00
|
|
|
pub db: Arc<Db>,
|
2026-06-20 19:51:10 +01:00
|
|
|
#[allow(dead_code)]
|
2026-06-20 19:41:47 +01:00
|
|
|
pub api_client: Arc<ApiClient>,
|
2026-06-20 19:51:10 +01:00
|
|
|
pub needs_auth: bool,
|
2026-06-20 19:56:41 +01:00
|
|
|
pub auth_error: Option<String>,
|
2026-06-21 14:21:14 +01:00
|
|
|
pub sync_stats: SyncStats,
|
|
|
|
|
last_sync_version: u64,
|
2026-06-21 15:45:14 +01:00
|
|
|
editing_task_id: Option<String>,
|
|
|
|
|
pending_date_key: bool,
|
2026-06-20 19:56:41 +01:00
|
|
|
auth_tx: std_mpsc::Sender<AuthEvent>,
|
2026-06-20 19:51:10 +01:00
|
|
|
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
2026-06-20 19:41:47 +01:00
|
|
|
sync_tx: mpsc::Sender<SyncCommand>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:56:41 +01:00
|
|
|
enum AuthEvent {
|
2026-06-20 20:55:08 +01:00
|
|
|
Ready,
|
2026-06-20 19:56:41 +01:00
|
|
|
Error(String),
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:51:10 +01:00
|
|
|
#[allow(dead_code)]
|
2026-06-20 19:41:47 +01:00
|
|
|
pub enum SyncCommand {
|
|
|
|
|
TriggerSync,
|
2026-06-21 14:21:14 +01:00
|
|
|
FullSync,
|
2026-06-20 19:51:10 +01:00
|
|
|
InitialSync,
|
2026-06-20 19:41:47 +01:00
|
|
|
Shutdown,
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App {
|
2026-06-20 19:41:47 +01:00
|
|
|
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
|
2026-06-21 10:04:08 +01:00
|
|
|
let has_token = api_client.has_token();
|
2026-06-20 19:51:10 +01:00
|
|
|
let (auth_tx, auth_rx) = std_mpsc::channel();
|
|
|
|
|
|
|
|
|
|
let show_popup = if has_token {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(Popup::DeviceAuth {
|
|
|
|
|
url: String::new(),
|
|
|
|
|
code: String::new(),
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
let lists = db.get_lists();
|
|
|
|
|
let tasks = if !lists.is_empty() {
|
|
|
|
|
let list_id = &lists[0].id;
|
|
|
|
|
db.get_tasks(list_id)
|
|
|
|
|
} else {
|
|
|
|
|
Vec::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
lists,
|
|
|
|
|
tasks,
|
|
|
|
|
selected_list: 0,
|
|
|
|
|
selected_task: 0,
|
|
|
|
|
focus: Focus::Tabs,
|
2026-06-20 19:51:10 +01:00
|
|
|
show_popup,
|
2026-06-20 19:38:12 +01:00
|
|
|
network_status: NetworkStatus::Online,
|
|
|
|
|
popup_input: String::new(),
|
|
|
|
|
popup_cursor: 0,
|
2026-06-21 15:45:14 +01:00
|
|
|
popup_secondary: String::new(),
|
|
|
|
|
popup_secondary_cursor: 0,
|
|
|
|
|
edit_field: 0,
|
2026-06-20 19:38:12 +01:00
|
|
|
draft_date: chrono::Local::now().naive_local(),
|
|
|
|
|
should_quit: false,
|
|
|
|
|
task_list_scroll: 0,
|
|
|
|
|
detail_scroll: 0,
|
|
|
|
|
db,
|
2026-06-20 19:41:47 +01:00
|
|
|
api_client,
|
2026-06-20 19:51:10 +01:00
|
|
|
needs_auth: !has_token,
|
2026-06-20 19:56:41 +01:00
|
|
|
auth_error: None,
|
2026-06-21 14:21:14 +01:00
|
|
|
sync_stats: SyncStats::default(),
|
|
|
|
|
last_sync_version: 0,
|
2026-06-21 15:45:14 +01:00
|
|
|
editing_task_id: None,
|
|
|
|
|
pending_date_key: false,
|
2026-06-20 19:56:41 +01:00
|
|
|
auth_tx,
|
2026-06-20 19:51:10 +01:00
|
|
|
auth_rx,
|
2026-06-20 19:41:47 +01:00
|
|
|
sync_tx,
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:56:41 +01:00
|
|
|
pub fn start_auth_process(&mut self) {
|
|
|
|
|
let api = self.api_client.clone();
|
|
|
|
|
let tx = self.auth_tx.clone();
|
2026-06-20 20:55:08 +01:00
|
|
|
|
2026-06-20 19:56:41 +01:00
|
|
|
self.auth_error = None;
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
|
|
|
rt.block_on(async move {
|
2026-06-21 10:04:08 +01:00
|
|
|
match api.start_and_wait_for_auth().await {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
let _ = tx.send(AuthEvent::Ready);
|
2026-06-20 19:56:41 +01:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2026-06-21 10:04:08 +01:00
|
|
|
let _ = tx.send(AuthEvent::Error(format!("{}", e)));
|
2026-06-20 19:56:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:51:10 +01:00
|
|
|
pub fn poll_auth(&mut self) {
|
|
|
|
|
if !self.needs_auth {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
while let Ok(event) = self.auth_rx.try_recv() {
|
|
|
|
|
match event {
|
2026-06-20 20:55:08 +01:00
|
|
|
AuthEvent::Ready => {
|
2026-06-20 19:51:10 +01:00
|
|
|
self.needs_auth = false;
|
2026-06-20 19:56:41 +01:00
|
|
|
self.auth_error = None;
|
2026-06-20 19:51:10 +01:00
|
|
|
self.show_popup = None;
|
|
|
|
|
let _ = self.sync_tx.try_send(SyncCommand::InitialSync);
|
|
|
|
|
}
|
|
|
|
|
AuthEvent::Error(msg) => {
|
2026-06-20 20:55:08 +01:00
|
|
|
self.auth_error = Some(msg);
|
2026-06-20 19:51:10 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn check_initial_load(&mut self) {
|
|
|
|
|
let lists = self.db.get_lists();
|
|
|
|
|
if !lists.is_empty() && self.lists.is_empty() {
|
|
|
|
|
self.lists = lists;
|
|
|
|
|
if !self.lists.is_empty() {
|
|
|
|
|
self.tasks = self.db.get_tasks(&self.lists[0].id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
pub fn refresh_if_needed(&mut self) {
|
|
|
|
|
if self.sync_stats.version != self.last_sync_version {
|
|
|
|
|
self.last_sync_version = self.sync_stats.version;
|
|
|
|
|
self.load_lists();
|
|
|
|
|
if !self.lists.is_empty() && self.selected_list < self.lists.len() {
|
|
|
|
|
self.tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
|
|
|
|
} else {
|
|
|
|
|
self.tasks.clear();
|
|
|
|
|
}
|
|
|
|
|
if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() {
|
|
|
|
|
self.selected_task = self.tasks.len() - 1;
|
|
|
|
|
} else if self.tasks.is_empty() {
|
|
|
|
|
self.selected_task = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:41:47 +01:00
|
|
|
fn trigger_sync(&self) {
|
|
|
|
|
let _ = self.sync_tx.try_send(SyncCommand::TriggerSync);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
fn trigger_full_sync(&self) {
|
|
|
|
|
let _ = self.sync_tx.try_send(SyncCommand::FullSync);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 15:45:14 +01:00
|
|
|
fn update_task_due(&mut self, due: chrono::NaiveDateTime) {
|
|
|
|
|
if self.tasks.is_empty() || self.selected_task >= self.tasks.len() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let task = &mut self.tasks[self.selected_task];
|
|
|
|
|
task.due = Some(due);
|
|
|
|
|
self.db.update_task(task).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Update,
|
|
|
|
|
&task.id,
|
|
|
|
|
&task.list_id,
|
|
|
|
|
&serde_json::to_string(task).unwrap_or_default(),
|
|
|
|
|
).ok();
|
|
|
|
|
self.trigger_sync();
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_due_today(&mut self) {
|
|
|
|
|
self.update_task_due(chrono::Local::now().naive_local());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_due_tomorrow_9am(&mut self) {
|
|
|
|
|
let tomorrow = chrono::Local::now().naive_local() + chrono::Duration::days(1);
|
|
|
|
|
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
|
|
|
|
|
let due = chrono::NaiveDateTime::new(tomorrow.date(), time);
|
|
|
|
|
self.update_task_due(due);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_due_next_week_9am(&mut self) {
|
|
|
|
|
let next_week = chrono::Local::now().naive_local() + chrono::Duration::days(7);
|
|
|
|
|
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
|
|
|
|
|
let due = chrono::NaiveDateTime::new(next_week.date(), time);
|
|
|
|
|
self.update_task_due(due);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_due_next_month_9am(&mut self) {
|
|
|
|
|
let next_month = chrono::Local::now().naive_local() + chrono::Duration::days(30);
|
|
|
|
|
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
|
|
|
|
|
let due = chrono::NaiveDateTime::new(next_month.date(), time);
|
|
|
|
|
self.update_task_due(due);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
pub fn handle_key(&mut self, key: KeyEvent) {
|
|
|
|
|
if let Some(ref popup) = self.show_popup.clone() {
|
|
|
|
|
self.handle_popup_key(key, popup);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 15:45:14 +01:00
|
|
|
if self.pending_date_key {
|
|
|
|
|
self.pending_date_key = false;
|
|
|
|
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Char('d') => self.set_due_today(),
|
|
|
|
|
KeyCode::Char('t') => self.set_due_tomorrow_9am(),
|
|
|
|
|
KeyCode::Char('w') => self.set_due_next_week_9am(),
|
|
|
|
|
KeyCode::Char('m') => self.set_due_next_month_9am(),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:34:01 +01:00
|
|
|
if key.code == KeyCode::Right && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
|
|
|
if !self.lists.is_empty() && self.selected_list + 1 < self.lists.len() {
|
|
|
|
|
self.selected_list += 1;
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if key.code == KeyCode::Left && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
|
|
|
if !self.lists.is_empty() && self.selected_list > 0 {
|
|
|
|
|
self.selected_list -= 1;
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Tab => {
|
|
|
|
|
self.focus = match self.focus {
|
|
|
|
|
Focus::Tabs => Focus::TaskList,
|
|
|
|
|
Focus::TaskList => Focus::Detail,
|
|
|
|
|
Focus::Detail => Focus::Tabs,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
|
|
|
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
|
|
|
|
self.reorder_task(-1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
|
|
|
|
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
|
|
|
|
self.reorder_task(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Up => match self.focus {
|
|
|
|
|
Focus::TaskList => {
|
|
|
|
|
if self.selected_task > 0 {
|
|
|
|
|
self.selected_task -= 1;
|
|
|
|
|
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Focus::Detail => {
|
|
|
|
|
self.detail_scroll = self.detail_scroll.saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
KeyCode::Down => match self.focus {
|
|
|
|
|
Focus::TaskList => {
|
|
|
|
|
if self.selected_task + 1 < self.tasks.len() {
|
|
|
|
|
self.selected_task += 1;
|
|
|
|
|
self.task_list_scroll += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Focus::Detail => {
|
|
|
|
|
self.detail_scroll += 1;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
KeyCode::Right => {
|
|
|
|
|
if self.focus == Focus::Tabs && !self.lists.is_empty() {
|
|
|
|
|
if self.selected_list + 1 < self.lists.len() {
|
|
|
|
|
self.selected_list += 1;
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Left => {
|
|
|
|
|
if self.focus == Focus::Tabs && !self.lists.is_empty() {
|
|
|
|
|
if self.selected_list > 0 {
|
|
|
|
|
self.selected_list -= 1;
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char('n') | KeyCode::Char('N') => {
|
2026-06-20 19:51:10 +01:00
|
|
|
if !self.needs_auth {
|
2026-06-21 15:45:14 +01:00
|
|
|
if self.focus == Focus::Tabs {
|
|
|
|
|
self.editing_task_id = None;
|
|
|
|
|
self.popup_input.clear();
|
|
|
|
|
self.popup_cursor = 0;
|
|
|
|
|
self.show_popup = Some(Popup::Input);
|
|
|
|
|
} else if !self.lists.is_empty() {
|
|
|
|
|
self.editing_task_id = None;
|
|
|
|
|
self.popup_input.clear();
|
|
|
|
|
self.popup_cursor = 0;
|
|
|
|
|
self.popup_secondary.clear();
|
|
|
|
|
self.popup_secondary_cursor = 0;
|
|
|
|
|
self.edit_field = 0;
|
|
|
|
|
self.show_popup = Some(Popup::EditTask { field: 0 });
|
|
|
|
|
}
|
2026-06-20 19:51:10 +01:00
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
KeyCode::Char('d') | KeyCode::Char('D') => {
|
2026-06-20 19:51:10 +01:00
|
|
|
if !self.needs_auth {
|
|
|
|
|
self.show_popup = Some(Popup::ConfirmDelete);
|
|
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
KeyCode::Char('e') | KeyCode::Char('E') => {
|
2026-06-20 19:51:10 +01:00
|
|
|
if !self.needs_auth && self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
2026-06-20 19:38:12 +01:00
|
|
|
let task = &self.tasks[self.selected_task];
|
2026-06-21 15:45:14 +01:00
|
|
|
self.editing_task_id = Some(task.id.clone());
|
2026-06-20 19:38:12 +01:00
|
|
|
self.popup_input = task.title.clone();
|
|
|
|
|
self.popup_cursor = task.title.len();
|
2026-06-21 15:45:14 +01:00
|
|
|
self.popup_secondary = task.notes.clone().unwrap_or_default();
|
|
|
|
|
self.popup_secondary_cursor = self.popup_secondary.len();
|
|
|
|
|
self.edit_field = 0;
|
|
|
|
|
self.show_popup = Some(Popup::EditTask { field: 0 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char('t') | KeyCode::Char('T') => {
|
|
|
|
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
|
|
|
|
self.pending_date_key = true;
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
if self.focus == Focus::Detail && !self.tasks.is_empty() {
|
|
|
|
|
self.show_popup = Some(Popup::DatePicker);
|
2026-06-21 15:45:14 +01:00
|
|
|
} else if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
|
|
|
|
let task = &mut self.tasks[self.selected_task];
|
|
|
|
|
task.status = match task.status {
|
|
|
|
|
TaskStatus::Completed => TaskStatus::NeedsAction,
|
|
|
|
|
TaskStatus::NeedsAction => TaskStatus::Completed,
|
|
|
|
|
};
|
|
|
|
|
self.db.update_task(task).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Update,
|
|
|
|
|
&task.id,
|
|
|
|
|
&task.list_id,
|
|
|
|
|
&serde_json::to_string(task).unwrap_or_default(),
|
|
|
|
|
).ok();
|
|
|
|
|
self.trigger_sync();
|
|
|
|
|
self.load_tasks();
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
2026-06-21 14:21:14 +01:00
|
|
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
self.trigger_full_sync();
|
|
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
|
|
|
|
self.should_quit = true;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) {
|
|
|
|
|
match popup {
|
2026-06-20 19:56:41 +01:00
|
|
|
Popup::DeviceAuth { url, code: _ } => match key.code {
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
if url.is_empty() && self.auth_error.is_none() {
|
|
|
|
|
self.start_auth_process();
|
2026-06-20 20:55:08 +01:00
|
|
|
self.show_popup = Some(Popup::DeviceAuth {
|
|
|
|
|
url: "starting...".to_string(),
|
|
|
|
|
code: String::new(),
|
|
|
|
|
});
|
|
|
|
|
} else if self.auth_error.is_some() {
|
|
|
|
|
self.auth_error = None;
|
|
|
|
|
self.start_auth_process();
|
|
|
|
|
self.show_popup = Some(Popup::DeviceAuth {
|
|
|
|
|
url: "starting...".to_string(),
|
|
|
|
|
code: String::new(),
|
|
|
|
|
});
|
2026-06-20 19:56:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-20 19:51:10 +01:00
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-06-20 19:38:12 +01:00
|
|
|
Popup::Input => match key.code {
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
let input = self.popup_input.trim().to_string();
|
|
|
|
|
if !input.is_empty() {
|
2026-06-21 15:45:14 +01:00
|
|
|
if self.focus == Focus::Tabs {
|
|
|
|
|
let list = TaskList {
|
|
|
|
|
id: uuid_v4(),
|
|
|
|
|
title: input,
|
|
|
|
|
};
|
|
|
|
|
self.db.insert_list(&list).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Create,
|
|
|
|
|
&list.id,
|
|
|
|
|
&list.id,
|
|
|
|
|
&serde_json::to_string(&list).unwrap_or_default(),
|
|
|
|
|
).ok();
|
|
|
|
|
self.trigger_sync();
|
|
|
|
|
self.load_lists();
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) => {
|
|
|
|
|
self.popup_input.insert(self.popup_cursor, c);
|
2026-06-21 15:45:14 +01:00
|
|
|
self.popup_cursor += c.len_utf8();
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
if self.popup_cursor > 0 {
|
2026-06-21 15:45:14 +01:00
|
|
|
let before = self.popup_input.floor_char_boundary(self.popup_cursor - 1);
|
|
|
|
|
self.popup_input.replace_range(before..self.popup_cursor, "");
|
|
|
|
|
self.popup_cursor = before;
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Delete => {
|
|
|
|
|
if self.popup_cursor < self.popup_input.len() {
|
2026-06-21 15:45:14 +01:00
|
|
|
let s = &self.popup_input[self.popup_cursor..];
|
|
|
|
|
if let Some(c) = s.chars().next() {
|
|
|
|
|
self.popup_input.replace_range(self.popup_cursor..self.popup_cursor + c.len_utf8(), "");
|
|
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Left => {
|
2026-06-21 15:45:14 +01:00
|
|
|
if self.popup_cursor > 0 {
|
|
|
|
|
self.popup_cursor = self.popup_input.floor_char_boundary(self.popup_cursor - 1);
|
|
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
KeyCode::Right => {
|
|
|
|
|
if self.popup_cursor < self.popup_input.len() {
|
2026-06-21 15:45:14 +01:00
|
|
|
let s = &self.popup_input[self.popup_cursor..];
|
|
|
|
|
if let Some(c) = s.chars().next() {
|
|
|
|
|
self.popup_cursor += c.len_utf8();
|
|
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Home => {
|
|
|
|
|
self.popup_cursor = 0;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::End => {
|
|
|
|
|
self.popup_cursor = self.popup_input.len();
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-06-21 15:45:14 +01:00
|
|
|
Popup::EditTask { field } => match key.code {
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.editing_task_id = None;
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Tab | KeyCode::Down => {
|
|
|
|
|
let new_field = (field + 1) % 2;
|
|
|
|
|
self.edit_field = new_field;
|
|
|
|
|
self.show_popup = Some(Popup::EditTask { field: new_field });
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Up => {
|
|
|
|
|
let new_field = (field + 1) % 2;
|
|
|
|
|
self.edit_field = new_field;
|
|
|
|
|
self.show_popup = Some(Popup::EditTask { field: new_field });
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
let title = self.popup_input.trim().to_string();
|
|
|
|
|
if title.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let notes = self.popup_secondary.trim().to_string();
|
|
|
|
|
let notes_opt = if notes.is_empty() { None } else { Some(notes) };
|
|
|
|
|
|
|
|
|
|
if let Some(task_id) = self.editing_task_id.take() {
|
|
|
|
|
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
|
|
|
|
|
task.title = title;
|
|
|
|
|
task.notes = notes_opt;
|
|
|
|
|
self.db.update_task(task).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Update,
|
|
|
|
|
&task.id,
|
|
|
|
|
&task.list_id,
|
|
|
|
|
&serde_json::to_string(task).unwrap_or_default(),
|
|
|
|
|
).ok();
|
|
|
|
|
self.trigger_sync();
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
} else if !self.lists.is_empty() {
|
|
|
|
|
let list_id = &self.lists[self.selected_list].id;
|
|
|
|
|
let task = Task {
|
|
|
|
|
id: uuid_v4(),
|
|
|
|
|
list_id: list_id.clone(),
|
|
|
|
|
title,
|
|
|
|
|
notes: notes_opt,
|
|
|
|
|
status: TaskStatus::NeedsAction,
|
|
|
|
|
due: None,
|
|
|
|
|
position: 0,
|
|
|
|
|
};
|
|
|
|
|
self.db.insert_task(&task).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Create,
|
|
|
|
|
&task.id,
|
|
|
|
|
list_id,
|
|
|
|
|
&serde_json::to_string(&task).unwrap_or_default(),
|
|
|
|
|
).ok();
|
|
|
|
|
self.trigger_sync();
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
self.popup_input.insert(self.popup_cursor, c);
|
|
|
|
|
self.popup_cursor += c.len_utf8();
|
|
|
|
|
} else {
|
|
|
|
|
self.popup_secondary.insert(self.popup_secondary_cursor, c);
|
|
|
|
|
self.popup_secondary_cursor += c.len_utf8();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
if self.popup_cursor > 0 {
|
|
|
|
|
let before = self.popup_input.floor_char_boundary(self.popup_cursor - 1);
|
|
|
|
|
self.popup_input.replace_range(before..self.popup_cursor, "");
|
|
|
|
|
self.popup_cursor = before;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if self.popup_secondary_cursor > 0 {
|
|
|
|
|
let before = self.popup_secondary.floor_char_boundary(self.popup_secondary_cursor - 1);
|
|
|
|
|
self.popup_secondary.replace_range(before..self.popup_secondary_cursor, "");
|
|
|
|
|
self.popup_secondary_cursor = before;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Delete => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
if self.popup_cursor < self.popup_input.len() {
|
|
|
|
|
let s = &self.popup_input[self.popup_cursor..];
|
|
|
|
|
if let Some(c) = s.chars().next() {
|
|
|
|
|
self.popup_input.replace_range(self.popup_cursor..self.popup_cursor + c.len_utf8(), "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if self.popup_secondary_cursor < self.popup_secondary.len() {
|
|
|
|
|
let s = &self.popup_secondary[self.popup_secondary_cursor..];
|
|
|
|
|
if let Some(c) = s.chars().next() {
|
|
|
|
|
self.popup_secondary.replace_range(self.popup_secondary_cursor..self.popup_secondary_cursor + c.len_utf8(), "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Left => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
if self.popup_cursor > 0 {
|
|
|
|
|
self.popup_cursor = self.popup_input.floor_char_boundary(self.popup_cursor - 1);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if self.popup_secondary_cursor > 0 {
|
|
|
|
|
self.popup_secondary_cursor = self.popup_secondary.floor_char_boundary(self.popup_secondary_cursor - 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Right => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
if self.popup_cursor < self.popup_input.len() {
|
|
|
|
|
let s = &self.popup_input[self.popup_cursor..];
|
|
|
|
|
if let Some(c) = s.chars().next() {
|
|
|
|
|
self.popup_cursor += c.len_utf8();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if self.popup_secondary_cursor < self.popup_secondary.len() {
|
|
|
|
|
let s = &self.popup_secondary[self.popup_secondary_cursor..];
|
|
|
|
|
if let Some(c) = s.chars().next() {
|
|
|
|
|
self.popup_secondary_cursor += c.len_utf8();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Home => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
self.popup_cursor = 0;
|
|
|
|
|
} else {
|
|
|
|
|
self.popup_secondary_cursor = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::End => {
|
|
|
|
|
if *field == 0 {
|
|
|
|
|
self.popup_cursor = self.popup_input.len();
|
|
|
|
|
} else {
|
|
|
|
|
self.popup_secondary_cursor = self.popup_secondary.len();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-06-20 19:38:12 +01:00
|
|
|
Popup::DatePicker => match key.code {
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
if !self.tasks.is_empty() {
|
|
|
|
|
let task = &mut self.tasks[self.selected_task];
|
|
|
|
|
task.due = Some(self.draft_date);
|
|
|
|
|
self.db.update_task(task).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Update,
|
|
|
|
|
&task.id,
|
|
|
|
|
&task.list_id,
|
|
|
|
|
&serde_json::to_string(task).unwrap_or_default(),
|
|
|
|
|
).ok();
|
2026-06-20 19:41:47 +01:00
|
|
|
self.trigger_sync();
|
2026-06-20 19:38:12 +01:00
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Up => {
|
|
|
|
|
self.draft_date = self.draft_date + chrono::Duration::hours(1);
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Down => {
|
|
|
|
|
self.draft_date = self.draft_date - chrono::Duration::hours(1);
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
Popup::ConfirmDelete => match key.code {
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
match self.focus {
|
|
|
|
|
Focus::Tabs => {
|
|
|
|
|
if self.selected_list < self.lists.len() {
|
|
|
|
|
let list_id = self.lists[self.selected_list].id.clone();
|
|
|
|
|
self.db.delete_list(&list_id).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Delete,
|
|
|
|
|
&list_id,
|
|
|
|
|
&list_id,
|
|
|
|
|
"",
|
|
|
|
|
).ok();
|
2026-06-20 19:41:47 +01:00
|
|
|
self.trigger_sync();
|
2026-06-20 19:38:12 +01:00
|
|
|
self.load_lists();
|
|
|
|
|
if self.selected_list >= self.lists.len() {
|
|
|
|
|
self.selected_list = self.lists.len().saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Focus::TaskList | Focus::Detail => {
|
|
|
|
|
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
|
|
|
|
|
let task = &self.tasks[self.selected_task];
|
|
|
|
|
let task_id = task.id.clone();
|
|
|
|
|
let list_id = task.list_id.clone();
|
|
|
|
|
self.db.delete_task(&task_id).ok();
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Delete,
|
|
|
|
|
&task_id,
|
|
|
|
|
&list_id,
|
|
|
|
|
"",
|
|
|
|
|
).ok();
|
2026-06-20 19:41:47 +01:00
|
|
|
self.trigger_sync();
|
2026-06-20 19:38:12 +01:00
|
|
|
self.load_tasks();
|
|
|
|
|
if self.selected_task >= self.tasks.len() {
|
|
|
|
|
self.selected_task = self.tasks.len().saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.show_popup = None;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reorder_task(&mut self, direction: i64) {
|
|
|
|
|
if self.tasks.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let new_index = self.selected_task as i64 + direction;
|
|
|
|
|
if new_index < 0 || new_index >= self.tasks.len() as i64 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let task_id = self.tasks[self.selected_task].id.clone();
|
|
|
|
|
let list_id = self.tasks[self.selected_task].list_id.clone();
|
|
|
|
|
|
2026-06-20 19:51:10 +01:00
|
|
|
let new_pos = self.tasks[new_index as usize].position;
|
2026-06-20 19:38:12 +01:00
|
|
|
|
|
|
|
|
if self.db.reorder_task(&task_id, new_pos).is_ok() {
|
|
|
|
|
let payload = serde_json::json!({
|
|
|
|
|
"task_id": task_id,
|
|
|
|
|
"new_position": new_pos
|
|
|
|
|
});
|
|
|
|
|
self.db.push_sync(
|
|
|
|
|
SyncAction::Reorder,
|
|
|
|
|
&task_id,
|
|
|
|
|
&list_id,
|
|
|
|
|
&payload.to_string(),
|
|
|
|
|
).ok();
|
2026-06-20 19:41:47 +01:00
|
|
|
self.trigger_sync();
|
2026-06-20 19:38:12 +01:00
|
|
|
self.selected_task = new_index as usize;
|
|
|
|
|
self.load_tasks();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_lists(&mut self) {
|
|
|
|
|
self.lists = self.db.get_lists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_tasks(&mut self) {
|
|
|
|
|
if self.selected_list < self.lists.len() {
|
|
|
|
|
self.tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
|
|
|
|
} else {
|
|
|
|
|
self.tasks.clear();
|
|
|
|
|
}
|
|
|
|
|
if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() {
|
|
|
|
|
self.selected_task = self.tasks.len() - 1;
|
|
|
|
|
} else if self.tasks.is_empty() {
|
|
|
|
|
self.selected_task = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn uuid_v4() -> String {
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
let now = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let nanos = now.as_nanos();
|
|
|
|
|
format!(
|
|
|
|
|
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
|
|
|
|
(nanos >> 32) as u32,
|
|
|
|
|
(nanos >> 16) as u16 & 0xffff,
|
|
|
|
|
(nanos >> 4) as u16 & 0xfff,
|
|
|
|
|
(nanos >> 48) as u16 & 0xffff,
|
|
|
|
|
(nanos & 0xfffffffffffff) as u64
|
|
|
|
|
)
|
|
|
|
|
}
|