use std::sync::mpsc as std_mpsc; use std::sync::Arc; use chrono::NaiveDateTime; use chrono::NaiveTime; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc; use crate::domain::models::*; use crate::infrastructure::api::ApiClient; use crate::infrastructure::db::Db; use crate::ui::{Focus, NetworkStatus, Popup}; #[derive(Debug, Clone, Default)] pub struct SyncStats { pub last_sync_time: Option, pub last_pull_time: Option, pub lists_changed: usize, pub tasks_changed: usize, pub version: u64, } pub struct App { pub lists: Vec, pub tasks: Vec, pub selected_list: usize, pub selected_task: usize, pub focus: Focus, pub show_popup: Option, pub network_status: NetworkStatus, pub popup_input: String, pub popup_cursor: usize, pub popup_secondary: String, pub popup_secondary_cursor: usize, pub edit_field: usize, pub draft_date: NaiveDateTime, pub should_quit: bool, pub task_list_scroll: u16, pub detail_scroll: u16, pub db: Arc, #[allow(dead_code)] pub api_client: Arc, pub needs_auth: bool, pub auth_error: Option, pub sync_stats: SyncStats, last_sync_version: u64, editing_task_id: Option, pending_date_key: bool, auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, } enum AuthEvent { Ready, Error(String), } #[allow(dead_code)] pub enum SyncCommand { TriggerSync, FullSync, InitialSync, Shutdown, } impl App { pub fn new(db: Arc, api_client: Arc, sync_tx: mpsc::Sender) -> Self { let has_token = api_client.has_token(); 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(), }) }; 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, show_popup, network_status: NetworkStatus::Online, popup_input: String::new(), popup_cursor: 0, popup_secondary: String::new(), popup_secondary_cursor: 0, edit_field: 0, draft_date: chrono::Local::now().naive_local(), should_quit: false, task_list_scroll: 0, detail_scroll: 0, db, api_client, needs_auth: !has_token, auth_error: None, sync_stats: SyncStats::default(), last_sync_version: 0, editing_task_id: None, pending_date_key: false, auth_tx, auth_rx, sync_tx, } } pub fn start_auth_process(&mut self) { let api = self.api_client.clone(); let tx = self.auth_tx.clone(); self.auth_error = None; std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { match api.start_and_wait_for_auth().await { Ok(()) => { let _ = tx.send(AuthEvent::Ready); } Err(e) => { let _ = tx.send(AuthEvent::Error(format!("{}", e))); } } }); }); } pub fn poll_auth(&mut self) { if !self.needs_auth { return; } while let Ok(event) = self.auth_rx.try_recv() { match event { AuthEvent::Ready => { self.needs_auth = false; self.auth_error = None; self.show_popup = None; let _ = self.sync_tx.try_send(SyncCommand::InitialSync); } AuthEvent::Error(msg) => { self.auth_error = Some(msg); } } } } 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); } } } 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; } } } fn trigger_sync(&self) { let _ = self.sync_tx.try_send(SyncCommand::TriggerSync); } fn trigger_full_sync(&self) { let _ = self.sync_tx.try_send(SyncCommand::FullSync); } 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); } pub fn handle_key(&mut self, key: KeyEvent) { if let Some(ref popup) = self.show_popup.clone() { self.handle_popup_key(key, popup); return; } 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; } 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; } 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') => { if !self.needs_auth { 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 }); } } } KeyCode::Char('d') | KeyCode::Char('D') => { if !self.needs_auth { self.show_popup = Some(Popup::ConfirmDelete); } } KeyCode::Char('e') | KeyCode::Char('E') => { if !self.needs_auth && self.focus == Focus::TaskList && !self.tasks.is_empty() { let task = &self.tasks[self.selected_task]; self.editing_task_id = Some(task.id.clone()); self.popup_input = task.title.clone(); self.popup_cursor = task.title.len(); 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; } } KeyCode::Enter => { if self.focus == Focus::Detail && !self.tasks.is_empty() { self.show_popup = Some(Popup::DatePicker); } 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(); } } KeyCode::Esc => { self.show_popup = None; } KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.trigger_full_sync(); } KeyCode::Char('q') | KeyCode::Char('Q') => { self.should_quit = true; } _ => {} } } fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) { match popup { Popup::DeviceAuth { url, code: _ } => match key.code { KeyCode::Enter => { if url.is_empty() && self.auth_error.is_none() { self.start_auth_process(); 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(), }); } } KeyCode::Esc => { self.show_popup = None; } _ => {} }, 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() { 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(); } } self.show_popup = None; } KeyCode::Char(c) => { self.popup_input.insert(self.popup_cursor, c); self.popup_cursor += c.len_utf8(); } KeyCode::Backspace => { 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; } } KeyCode::Delete => { 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(), ""); } } } KeyCode::Left => { if self.popup_cursor > 0 { self.popup_cursor = self.popup_input.floor_char_boundary(self.popup_cursor - 1); } } KeyCode::Right => { 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(); } } } KeyCode::Home => { self.popup_cursor = 0; } KeyCode::End => { self.popup_cursor = self.popup_input.len(); } _ => {} }, 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, created_at: None, updated_at: None, }; 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(); } } _ => {} }, 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(); self.trigger_sync(); 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(); self.trigger_sync(); 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(); self.trigger_sync(); 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(); let new_pos = self.tasks[new_index as usize].position; 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(); self.trigger_sync(); 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 ) }