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)
This commit is contained in:
Ruben Rosario
2026-06-20 19:51:10 +01:00
parent 71befdf9f8
commit 320a9c2572
3 changed files with 149 additions and 32 deletions
+104 -32
View File
@@ -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<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;
@@ -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], "-");
}
}
+8
View File
@@ -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::<OAuthToken>(&s).ok())
.is_some()
}
pub async fn load_token(&self) -> Option<OAuthToken> {
let content = std::fs::read_to_string(&self.token_path).ok()?;
serde_json::from_str(&content).ok()
+37
View File
@@ -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<Db>,
api: &Arc<ApiClient>,
network_status: &Arc<Mutex<NetworkStatus>>,
) {
*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<Db>,
api: &Arc<ApiClient>,