Files
task_app_rust/src/app.rs
T

504 lines
18 KiB
Rust
Raw Normal View History

use std::sync::mpsc as std_mpsc;
use std::sync::Arc;
use chrono::NaiveDateTime;
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};
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,
pub draft_date: NaiveDateTime,
pub should_quit: bool,
pub task_list_scroll: u16,
pub detail_scroll: u16,
pub db: Arc<Db>,
#[allow(dead_code)]
pub api_client: Arc<ApiClient>,
pub needs_auth: bool,
auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>,
}
#[allow(dead_code)]
pub enum SyncCommand {
TriggerSync,
InitialSync,
Shutdown,
}
enum AuthEvent {
DeviceCode(String, String),
Complete,
Error(String),
}
impl App {
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
let has_token = api_client.token_file_exists();
let (auth_tx, auth_rx) = std_mpsc::channel();
if !has_token {
let api = api_client.clone();
let tx = auth_tx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
match api.authenticate().await {
Ok((url, code)) => {
let _ = tx.send(AuthEvent::DeviceCode(url, code));
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
if api.token_file_exists() {
let _ = tx.send(AuthEvent::Complete);
break;
}
}
}
Err(_) => {
let _ = tx.send(AuthEvent::Error("Auth failed".to_string()));
}
}
});
});
}
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,
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_rx,
sync_tx,
}
}
pub fn poll_auth(&mut self) {
if !self.needs_auth {
return;
}
while let Ok(event) = self.auth_rx.try_recv() {
match event {
AuthEvent::DeviceCode(url, code) => {
self.show_popup = Some(Popup::DeviceAuth {
url,
code,
});
}
AuthEvent::Complete => {
self.needs_auth = false;
self.show_popup = None;
let _ = self.sync_tx.try_send(SyncCommand::InitialSync);
}
AuthEvent::Error(msg) => {
self.needs_auth = false;
self.show_popup = None;
eprintln!("Auth error: {}", 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);
}
}
}
fn trigger_sync(&self) {
let _ = self.sync_tx.try_send(SyncCommand::TriggerSync);
}
pub fn handle_key(&mut self, key: KeyEvent) {
if let Some(ref popup) = self.show_popup.clone() {
self.handle_popup_key(key, popup);
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 {
self.popup_input.clear();
self.popup_cursor = 0;
self.show_popup = Some(Popup::Input);
}
}
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.popup_input = task.title.clone();
self.popup_cursor = task.title.len();
self.show_popup = Some(Popup::Input);
}
}
KeyCode::Enter => {
if self.focus == Focus::Detail && !self.tasks.is_empty() {
self.show_popup = Some(Popup::DatePicker);
}
}
KeyCode::Esc => {
self.show_popup = None;
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
self.should_quit = true;
}
_ => {}
}
}
fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) {
match popup {
Popup::DeviceAuth { .. } => match key.code {
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() {
match 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();
}
Focus::TaskList => {
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: input,
notes: None,
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();
}
}
Focus::Detail => {
if !self.tasks.is_empty() {
let task = &mut self.tasks[self.selected_task];
task.title = input;
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::Char(c) => {
self.popup_input.insert(self.popup_cursor, c);
self.popup_cursor += 1;
}
KeyCode::Backspace => {
if self.popup_cursor > 0 {
self.popup_cursor -= 1;
self.popup_input.remove(self.popup_cursor);
}
}
KeyCode::Delete => {
if self.popup_cursor < self.popup_input.len() {
self.popup_input.remove(self.popup_cursor);
}
}
KeyCode::Left => {
self.popup_cursor = self.popup_cursor.saturating_sub(1);
}
KeyCode::Right => {
if self.popup_cursor < self.popup_input.len() {
self.popup_cursor += 1;
}
}
KeyCode::Home => {
self.popup_cursor = 0;
}
KeyCode::End => {
self.popup_cursor = self.popup_input.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
)
}