feat(api): google tasks oauth, sync engine, and background worker

- ApiClient with manual OAuth2 Device Flow (no yup-oauth2 dependency)
- Devide auth: POST device/code -> show URL+code -> poll token endpoint
- Token persistence in ~/.config/task_app/token.json
- CRUD: create_task, update_task, delete_task, move_task via Google Tasks API
- fetch_lists and fetch_tasks for initial sync import
- Db wraps Connection in std::sync::Mutex for thread-safe sharing via Arc
- Sync engine: background thread with tokio runtime, processes queue every 30s
- process_sync_queue drains sync_queue and calls API methods
- trigger_sync() called after every local mutation (create/update/delete/reorder)
- Network status propagated to UI (Online/Offline/Syncing)
- Initial sync skeleton ready for full import flow
This commit is contained in:
Ruben Rosario
2026-06-20 19:41:47 +01:00
parent 3b6726a726
commit 71befdf9f8
4 changed files with 646 additions and 41 deletions
+28 -6
View File
@@ -1,7 +1,11 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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};
@@ -19,11 +23,18 @@ pub struct App {
pub should_quit: bool,
pub task_list_scroll: u16,
pub detail_scroll: u16,
pub db: Db,
pub db: Arc<Db>,
pub api_client: Arc<ApiClient>,
sync_tx: mpsc::Sender<SyncCommand>,
}
pub enum SyncCommand {
TriggerSync,
Shutdown,
}
impl App {
pub fn new(db: Db) -> Self {
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
let lists = db.get_lists();
let tasks = if !lists.is_empty() {
let list_id = &lists[0].id;
@@ -47,9 +58,15 @@ impl App {
task_list_scroll: 0,
detail_scroll: 0,
db,
api_client,
sync_tx,
}
}
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);
@@ -167,6 +184,7 @@ impl App {
&list.id,
&serde_json::to_string(&list).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_lists();
}
Focus::TaskList => {
@@ -188,13 +206,14 @@ impl App {
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;
task.title = input;
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
@@ -202,9 +221,8 @@ impl App {
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_tasks();
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
}
}
}
}
@@ -257,6 +275,7 @@ impl App {
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_tasks();
}
self.show_popup = None;
@@ -285,6 +304,7 @@ impl App {
&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);
@@ -304,6 +324,7 @@ impl App {
&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);
@@ -354,6 +375,7 @@ impl App {
&list_id,
&payload.to_string(),
).ok();
self.trigger_sync();
self.selected_task = new_index as usize;
self.load_tasks();
}