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:
+104
-32
@@ -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], "-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user