From 320a9c257251c38461b90a5c01e05467ebe6bed2 Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sat, 20 Jun 2026 19:51:10 +0100 Subject: [PATCH] fix: wire initial sync, oauth flow, and eliminate warnings - Add token_file_exists() to ApiClient for sync token check - App::new now checks for token on startup; shows DeviceAuth popup if missing - Background thread starts OAuth Device Flow automatically when no token - App::poll_auth() called each frame to detect auth completion - Auth completion triggers SyncCommand::InitialSync - run_initial_sync fetches all lists and tasks via Google Tasks API - Stores results in local DB via replace_all_lists / replace_all_tasks - App::check_initial_load() refreshes UI from DB after initial sync - Removed all compile warnings (dead_code annotations) --- src/app.rs | 136 +++++++++++++++++++++++++++++--------- src/infrastructure/api.rs | 8 +++ src/main.rs | 37 +++++++++++ 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/src/app.rs b/src/app.rs index 352cd1d..c0f1e2a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use std::sync::mpsc as std_mpsc; use std::sync::Arc; use chrono::NaiveDateTime; @@ -24,17 +25,65 @@ pub struct App { pub task_list_scroll: u16, pub detail_scroll: u16, pub db: Arc, + #[allow(dead_code)] pub api_client: Arc, + pub needs_auth: bool, + auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, } +#[allow(dead_code)] pub enum SyncCommand { TriggerSync, + InitialSync, Shutdown, } +enum AuthEvent { + DeviceCode(String, String), + Complete, + Error(String), +} + impl App { pub fn new(db: Arc, api_client: Arc, sync_tx: mpsc::Sender) -> 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; @@ -49,7 +98,7 @@ impl App { selected_list: 0, selected_task: 0, focus: Focus::Tabs, - show_popup: None, + show_popup, network_status: NetworkStatus::Online, popup_input: String::new(), popup_cursor: 0, @@ -59,10 +108,48 @@ impl App { 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); } @@ -132,15 +219,19 @@ impl App { } } KeyCode::Char('n') | KeyCode::Char('N') => { - self.popup_input.clear(); - self.popup_cursor = 0; - self.show_popup = Some(Popup::Input); + if !self.needs_auth { + self.popup_input.clear(); + self.popup_cursor = 0; + self.show_popup = Some(Popup::Input); + } } KeyCode::Char('d') | KeyCode::Char('D') => { - self.show_popup = Some(Popup::ConfirmDelete); + if !self.needs_auth { + self.show_popup = Some(Popup::ConfirmDelete); + } } KeyCode::Char('e') | KeyCode::Char('E') => { - if self.focus == Focus::TaskList && !self.tasks.is_empty() { + 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(); @@ -164,6 +255,12 @@ impl App { 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; @@ -336,12 +433,6 @@ impl App { } _ => {} }, - Popup::DeviceAuth { .. } => match key.code { - KeyCode::Enter | KeyCode::Esc => { - self.show_popup = None; - } - _ => {} - }, } } @@ -358,11 +449,7 @@ impl App { let task_id = self.tasks[self.selected_task].id.clone(); let list_id = self.tasks[self.selected_task].list_id.clone(); - let new_pos = if direction > 0 { - self.tasks[new_index as usize].position - } else { - self.tasks[new_index as usize].position - }; + 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!({ @@ -414,18 +501,3 @@ fn uuid_v4() -> String { (nanos & 0xfffffffffffff) as u64 ) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_uuid_v4_format() { - let id = uuid_v4(); - assert_eq!(id.len(), 36); - assert_eq!(&id[8..9], "-"); - assert_eq!(&id[13..14], "-"); - assert_eq!(&id[18..19], "-"); - assert_eq!(&id[23..24], "-"); - } -} diff --git a/src/infrastructure/api.rs b/src/infrastructure/api.rs index f61b02a..6e7addf 100644 --- a/src/infrastructure/api.rs +++ b/src/infrastructure/api.rs @@ -17,6 +17,7 @@ pub struct OAuthToken { } #[derive(Debug)] +#[allow(dead_code)] pub enum ApiError { Network(String), Auth(String), @@ -47,6 +48,13 @@ impl ApiClient { } } + pub fn token_file_exists(&self) -> bool { + self.token_path.exists() && std::fs::read_to_string(&self.token_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .is_some() + } + pub async fn load_token(&self) -> Option { let content = std::fs::read_to_string(&self.token_path).ok()?; serde_json::from_str(&content).ok() diff --git a/src/main.rs b/src/main.rs index 013661b..37fa4d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,18 @@ fn main() -> io::Result<()> { }); }); + // Trigger initial sync if already authenticated + if !app.needs_auth { + let _ = sync_tx.try_send(SyncCommand::InitialSync); + } + while !app.should_quit { + // Poll auth status (non-blocking) + app.poll_auth(); + + // Check if initial sync has loaded data into DB + app.check_initial_load(); + terminal.draw(|frame| { let status = { let guard = network_status.blocking_lock(); @@ -109,6 +120,9 @@ async fn run_sync_engine( Some(SyncCommand::TriggerSync) => { process_sync_queue(&db, &api, &network_status).await; } + Some(SyncCommand::InitialSync) => { + run_initial_sync(&db, &api, &network_status).await; + } Some(SyncCommand::Shutdown) | None => break, } } @@ -116,6 +130,29 @@ async fn run_sync_engine( } } +async fn run_initial_sync( + db: &Arc, + api: &Arc, + network_status: &Arc>, +) { + *network_status.lock().await = NetworkStatus::Syncing; + + match api.fetch_lists().await { + Ok(lists) => { + db.replace_all_lists(&lists).ok(); + for list in &lists { + if let Ok(tasks) = api.fetch_tasks(&list.id).await { + db.replace_all_tasks(&list.id, &tasks).ok(); + } + } + *network_status.lock().await = NetworkStatus::Online; + } + Err(_) => { + *network_status.lock().await = NetworkStatus::Offline; + } + } +} + async fn process_sync_queue( db: &Arc, api: &Arc,