use crate::api::{models::{CfcTask, WaterRecord}, ApiClient}; use crate::app_state::AppState; use crate::config::PetConfig; use crate::state::{map_state, PetState, PetStatePayload}; use chrono::Timelike; use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager}; /// 启动轮询任务:启动即同步吉祥物+喝水检查,然后 30s 任务 / 60s 健康 / 1h 喝水。 /// 返回 JoinHandle 以便 save_settings 时 abort 重启。 pub fn spawn(app: AppHandle, api: Arc) -> tauri::async_runtime::JoinHandle<()> { tauri::async_runtime::spawn(async move { sync_mascot(&app, &api).await; check_water(&app, &api).await; let mut tasks_iv = tokio::time::interval(Duration::from_secs(30)); let mut health_iv = tokio::time::interval(Duration::from_secs(60)); let mut water_iv = tokio::time::interval(Duration::from_secs(3600)); loop { tokio::select! { _ = tasks_iv.tick() => poll_tasks(&app, &api).await, _ = health_iv.tick() => poll_health(&app, &api).await, _ = water_iv.tick() => check_water(&app, &api).await, } } }) } fn in_active_hour(hour: u32) -> bool { (10..=20).contains(&hour) } async fn sync_mascot(app: &AppHandle, api: &ApiClient) { let Ok(mascot) = api.mascot_get().await else { return }; let pet_name = mascot.mascot.as_deref().unwrap_or("xibao"); let path = format!("DefaultPets/{pet_name}/pet-config.json"); // 优先读嵌入资产;失败回退 resource_dir let bytes = app .asset_resolver() .get(path.clone()) .map(|a| a.bytes) .or_else(|| std::fs::read(app.path().resource_dir().unwrap_or_default().join(&path)).ok()); let Some(bytes) = bytes else { return }; if let Ok(json) = String::from_utf8(bytes) { if let Ok(cfg) = PetConfig::parse(&json) { if let Ok(payload) = serde_json::to_string(&cfg) { let _ = app.emit("pet://config", payload); } } } } async fn poll_tasks(app: &AppHandle, api: &ApiClient) { match api.tasks_today().await { Ok(tasks) => { let state = map_state(Some(&tasks), None, false, false); let payload = PetStatePayload { state, reason: Some("tasks".into()), data: None }; let _ = app.emit("pet://state", payload); // 选出最紧急的待办任务作为「点提醒即完成」的目标 let urgent = pick_urgent_task(&tasks); let new_id = urgent.as_ref().map(|t| t.id); let changed = match app.try_state::() { Some(s) => { let mut cur = s.pending_task.lock().unwrap(); let changed = *cur != new_id; *cur = new_id; changed } None => false, }; if changed { if let Some(t) = &urgent { let title = t.title.clone().unwrap_or_else(|| "待办任务".into()); let _ = app.emit( "pet://bubble", serde_json::json!({ "text": format!("「{title}」快到点了,点我完成!"), "durationMs": 15000 }), ); } } } Err(_) => { let _ = app.emit( "pet://bubble", serde_json::json!({"text": "连接后端失败,请检查服务", "durationMs": 5000}), ); } } } /// 从今日任务中选出最紧急、可一键完成的待办任务(未完成且已到期/今日到期) fn pick_urgent_task(tasks: &[CfcTask]) -> Option { let today = chrono::Local::now().date_naive(); tasks .iter() .filter(|t| matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS"))) .filter(|t| { t.deadline .as_ref() .and_then(|d| d.to_local_date()) .map(|due| due <= today) .unwrap_or(false) }) .min_by_key(|t| t.deadline.as_ref().and_then(|d| d.to_local_date())) .cloned() } async fn poll_health(app: &AppHandle, api: &ApiClient) { match (api.energy_overview().await, api.water_list().await) { (Ok(energy), Ok(water)) => { let done = water_done_today(&water); let state = map_state(None, Some(&energy), done, false); let payload = PetStatePayload { state, reason: Some("health".into()), data: None }; let _ = app.emit("pet://state", payload); } _ => { /* 静默,与现版一致 */ } } } async fn check_water(app: &AppHandle, api: &ApiClient) { let hour = chrono::Local::now().hour(); if !in_active_hour(hour) { return; } let water = match api.water_list().await { Ok(w) => w, Err(_) => return, }; if !water_done_today(&water) { let payload = PetStatePayload { state: PetState::WaitingForInteraction, reason: Some("drink_water".into()), data: None, }; let _ = app.emit("pet://state", payload); let _ = app.emit( "pet://bubble", serde_json::json!({"text": "该喝水啦~点我确认完成!", "durationMs": 15000}), ); } } /// 今天是否已有喝水记录(drunkAt 优先,回退 createdAt) fn water_done_today(records: &[WaterRecord]) -> bool { let today = chrono::Local::now().date_naive(); records.iter().any(|r| { r.drunk_at .as_ref() .or(r.created_at.as_ref()) .and_then(|d| d.to_local_date()) .map(|d| d == today) .unwrap_or(false) }) } #[cfg(test)] mod tests { use super::*; use crate::api::models::FlexDate; #[test] fn active_hour_window_is_10_to_20_inclusive() { assert!(!in_active_hour(9)); assert!(in_active_hour(10)); assert!(in_active_hour(15)); assert!(in_active_hour(20)); assert!(!in_active_hour(21)); } fn water(drunk_at: Option<&str>, created_at: Option<&str>) -> WaterRecord { WaterRecord { id: None, member_id: None, checkin_id: None, amount_ml: None, water_type: None, drunk_at: drunk_at.map(|s| FlexDate::Iso(s.to_string())), created_at: created_at.map(|s| FlexDate::Iso(s.to_string())), } } #[test] fn water_done_today_matches_today_only() { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let today_dt = format!("{today}T08:00:00"); assert!(water_done_today(&[water(Some(&today_dt), None)])); assert!(!water_done_today(&[water(Some("2000-01-01T08:00:00"), None)])); assert!(!water_done_today(&[])); } #[test] fn water_done_today_falls_back_to_created_at() { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let today_dt = format!("{today}T09:30:00"); assert!(water_done_today(&[water(None, Some(&today_dt))])); } fn task(id: i64, status: &str, due: Option<&str>) -> CfcTask { CfcTask { id, title: None, status: Some(status.to_string()), deadline: due.map(|s| FlexDate::Iso(s.to_string())), } } #[test] fn pick_urgent_prefers_earliest_due_pending() { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let tasks = vec![ task(1, "COMPLETED", Some("2000-01-01")), task(2, "PENDING", Some(&today)), task(3, "PENDING", Some("2000-01-01")), ]; assert_eq!(pick_urgent_task(&tasks).unwrap().id, 3); } #[test] fn pick_urgent_none_when_not_yet_due() { let tasks = vec![task(1, "PENDING", Some("2099-12-31"))]; assert!(pick_urgent_task(&tasks).is_none()); } #[test] fn pick_urgent_none_when_no_deadline() { let tasks = vec![task(1, "PENDING", None)]; assert!(pick_urgent_task(&tasks).is_none()); } }