//! The egui application: config panel, record/stop controls, case list, //! state transitions driven by recorder + uploader + poller channels. use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use doctate_client_core::{ CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache, }; use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem}; use eframe::egui; use tokio::runtime::Runtime; use tokio::sync::{mpsc, watch}; use tracing::{error, info, warn}; use uuid::Uuid; use crate::config::Config; use crate::recorder::{Recorder, RecorderEvent}; use crate::state::AppState; use crate::uploader::{write_sidecar, PendingUpload, UploadEvent, Uploader}; pub struct DoctateApp { runtime: Arc, config: Option, pending_dir: PathBuf, state: AppState, server_url_input: String, api_key_input: String, http_client: Arc, case_store: Arc, snapshot_rx: watch::Receiver>>, snapshot_cache: Arc, recorder: Option, recorder_events: Option>, uploader: Option, upload_events: Option>, poller: Option, } enum UiAction { SaveConfig, StartNew, Continue(Uuid), OpenWeb(Uuid), Stop, DismissError, } impl DoctateApp { pub fn new( runtime: Arc, pending_dir: PathBuf, cases_dir: PathBuf, snapshot_cache_path: PathBuf, ) -> Self { let config = match Config::load() { Ok(Some(c)) => Some(c), Ok(None) => None, Err(e) => { warn!(error = %e, "config load failed"); None } }; let server_url_input = config .as_ref() .map(|c| c.server_url.clone()) .unwrap_or_default(); let api_key_input = config .as_ref() .map(|c| c.api_key.clone()) .unwrap_or_default(); // Open the case store before any background task — the store // owns the watch::Sender that drives the UI list. let (store, snapshot_rx) = runtime .block_on(async { CaseStore::open(cases_dir).await }) .expect("open case store"); let case_store = Arc::new(store); let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path)); let http_client = Arc::new(reqwest::Client::new()); let (state, uploader, upload_events, poller) = if let Some(cfg) = &config { let (up, rx, pol) = spawn_background( &runtime, cfg.clone(), pending_dir.clone(), http_client.clone(), case_store.clone(), snapshot_cache.clone(), ); (AppState::Idle, Some(up), Some(rx), Some(pol)) } else { (AppState::NotConfigured, None, None, None) }; Self { runtime, config, pending_dir, state, server_url_input, api_key_input, http_client, case_store, snapshot_rx, snapshot_cache, recorder: None, recorder_events: None, uploader, upload_events, poller, } } fn on_save_config(&mut self) { let cfg = Config { server_url: self.server_url_input.trim().to_string(), api_key: self.api_key_input.trim().to_string(), oneliner_poll_interval_seconds: self .config .as_ref() .and_then(|c| c.oneliner_poll_interval_seconds), oneliner_window_hours: self .config .as_ref() .and_then(|c| c.oneliner_window_hours), }; if let Err(e) = cfg.save() { error!(error = %e, "config save failed"); self.state = AppState::Error { message: format!("Speichern: {e}"), }; return; } info!("config saved"); // New API key = potentially different user. Wipe local state so // we don't leak old user's cases into the new user's view. let store = self.case_store.clone(); let cache = self.snapshot_cache.clone(); self.runtime.block_on(async move { if let Err(e) = store.clear_all().await { warn!(error = %e, "case store clear failed on config change"); } if let Err(e) = cache.clear().await { warn!(error = %e, "snapshot cache clear failed on config change"); } }); // Drop old background tasks explicitly so their AbortHandle / // channel-close fires before the new ones spawn. self.poller = None; self.uploader = None; self.upload_events = None; let (up, rx, pol) = spawn_background( &self.runtime, cfg.clone(), self.pending_dir.clone(), self.http_client.clone(), self.case_store.clone(), self.snapshot_cache.clone(), ); self.uploader = Some(up); self.upload_events = Some(rx); self.poller = Some(pol); self.config = Some(cfg); self.state = AppState::Idle; } fn on_start_new(&mut self) { self.start_recording_for(Uuid::new_v4(), /* is_continue */ false); } fn on_continue(&mut self, case_id: Uuid) { self.start_recording_for(case_id, /* is_continue */ true); } fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) { if !matches!(self.state, AppState::Idle) { return; } if let Err(e) = std::fs::create_dir_all(&self.pending_dir) { self.state = AppState::Error { message: format!("Pending-Verzeichnis: {e}"), }; return; } let recorded_at = now_rfc3339(); let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at)); let output_path = self.pending_dir.join(format!("{stem}.m4a")); // Persist the case marker before recording so the UI list // reflects the new case immediately (even if the recorder fails // to start, the marker stays — harmless and reproducible). let store = self.case_store.clone(); let now = time::OffsetDateTime::now_utc(); self.runtime.block_on(async move { let res = if is_continue { store.mark_activity(case_id, now).await.map(|_| ()) } else { store.create_local(case_id, now).await.map(|_| ()) }; if let Err(e) = res { warn!(error = %e, "case store write failed"); } }); let _guard = self.runtime.enter(); match Recorder::start(output_path) { Ok((recorder, events)) => { self.recorder = Some(recorder); self.recorder_events = Some(events); self.state = AppState::Recording { case_id, recorded_at, started_at: Instant::now(), }; } Err(e) => { self.state = AppState::Error { message: format!("Aufnahme starten: {e}"), }; } } } fn on_stop(&mut self) { let AppState::Recording { case_id, recorded_at, .. } = &self.state else { return; }; let case_id = *case_id; let recorded_at = recorded_at.clone(); if let Some(rec) = self.recorder.take() { rec.stop(); } self.state = AppState::FinalizingRecording { case_id, recorded_at, }; } fn on_open_web(&self, case_id: Uuid) { let Some(cfg) = &self.config else { return; }; let url = format!( "{}/web/cases/{}", cfg.server_url.trim_end_matches('/'), case_id ); if let Err(e) = webbrowser::open(&url) { warn!(url = %url, error = %e, "open browser failed"); } } fn drain_recorder_events(&mut self) { let Some(rx) = self.recorder_events.as_mut() else { return; }; let mut events = Vec::new(); while let Ok(event) = rx.try_recv() { events.push(event); } for event in events { match event { RecorderEvent::Started => {} RecorderEvent::Finished { output } => { self.handle_recording_finished(output); } RecorderEvent::Failed { error: err } => { error!(error = %err, "recorder failed"); self.state = AppState::Error { message: format!("Aufnahme: {err}"), }; self.recorder_events = None; self.recorder = None; } } } } fn handle_recording_finished(&mut self, output: PathBuf) { let (case_id, recorded_at) = match &self.state { AppState::FinalizingRecording { case_id, recorded_at, } => (*case_id, recorded_at.clone()), AppState::Recording { case_id, recorded_at, .. } => (*case_id, recorded_at.clone()), other => { warn!(state = ?other, "recorder finished in unexpected state"); self.recorder_events = None; self.recorder = None; return; } }; // Bump activity on the marker so the case rises in the list. let store = self.case_store.clone(); let now = time::OffsetDateTime::now_utc(); self.runtime.block_on(async move { if let Err(e) = store.mark_activity(case_id, now).await { warn!(error = %e, "mark_activity on recording finished failed"); } }); let upload = PendingUpload { case_id, recorded_at, file: output, }; if let Err(e) = write_sidecar(&upload) { self.state = AppState::Error { message: format!("Sidecar schreiben: {e}"), }; return; } let Some(uploader) = &self.uploader else { self.state = AppState::Error { message: "Upload-Worker nicht verfügbar".into(), }; return; }; if let Err(e) = uploader.submit(upload) { self.state = AppState::Error { message: format!("Upload-Submit: {e}"), }; return; } self.state = AppState::Uploading { case_id }; self.recorder_events = None; self.recorder = None; } fn drain_upload_events(&mut self) { let Some(rx) = self.upload_events.as_mut() else { return; }; let mut events = Vec::new(); while let Ok(event) = rx.try_recv() { events.push(event); } for event in events { match event { UploadEvent::Started(case_id) => { info!(case_id = %case_id, "upload attempt started"); } UploadEvent::Succeeded { case_id, status } => { info!(case_id = %case_id, status = ?status, "upload succeeded"); if let AppState::Uploading { case_id: current } = &self.state && *current == case_id { self.state = AppState::Idle; } } UploadEvent::Failed { case_id, reason, will_retry, } => { warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed"); if !will_retry && let AppState::Uploading { case_id: current } = &self.state && *current == case_id { self.state = AppState::Error { message: reason }; } } } } } fn render_controls(&mut self, ui: &mut egui::Ui) -> Option { match &self.state { AppState::NotConfigured => { ui.label("Server URL:"); ui.add( egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0), ); ui.add_space(4.0); ui.label("API Key:"); ui.add( egui::TextEdit::singleline(&mut self.api_key_input) .desired_width(280.0) .password(true), ); ui.add_space(8.0); if ui.button("Speichern").clicked() { return Some(UiAction::SaveConfig); } None } AppState::Idle => { if ui.button("● Neu").clicked() { return Some(UiAction::StartNew); } None } AppState::Recording { started_at, .. } => { let elapsed = started_at.elapsed(); ui.label(format!( "● REC {:02}:{:02}", elapsed.as_secs() / 60, elapsed.as_secs() % 60 )); ui.add_space(4.0); if ui.button("■ Stop").clicked() { return Some(UiAction::Stop); } None } AppState::FinalizingRecording { .. } => { ui.spinner(); ui.label("Speichere Aufnahme…"); None } AppState::Uploading { .. } => { ui.spinner(); ui.label("Upload läuft…"); None } AppState::Error { message } => { ui.colored_label(egui::Color32::RED, message); ui.add_space(8.0); if ui.button("OK").clicked() { return Some(UiAction::DismissError); } None } } } fn render_case_list(&self, ui: &mut egui::Ui) -> Option { let snapshot = self.snapshot_rx.borrow().clone(); let window = self .config .as_ref() .map(Config::window_hours) .unwrap_or(72); let cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(window as i64); let visible: Vec<&CaseMarker> = snapshot .iter() .filter(|m| parse_rfc3339(&m.last_activity_at).is_none_or(|t| t >= cutoff)) .collect(); if visible.is_empty() { ui.add_space(8.0); ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute."); return None; } let mut action = None; let idle = matches!(self.state, AppState::Idle); egui::ScrollArea::vertical().show(ui, |ui| { for marker in visible { ui.separator(); ui.add_space(4.0); let time_str = extract_hhmm(&marker.last_activity_at); let oneliner = marker .oneliner .as_deref() .map(str::to_owned) .unwrap_or_else(|| "⏳".to_owned()); ui.label(format!("{time_str} · {oneliner}")); ui.horizontal(|ui| { if ui .add_enabled(idle, egui::Button::new("▶ Fortsetzen")) .clicked() { action = Some(UiAction::Continue(marker.case_id)); } if ui.button("🌐 Öffnen").clicked() { action = Some(UiAction::OpenWeb(marker.case_id)); } }); ui.add_space(4.0); } }); action } fn render_staleness(&self, ui: &mut egui::Ui) { let Some(poller) = &self.poller else { return; }; let Some(last) = poller.last_success() else { ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung"); return; }; let age = last.elapsed(); let secs = age.as_secs(); let (color, text) = if secs < 30 { (egui::Color32::DARK_GREEN, format!("🟢 zuletzt: vor {secs} s")) } else if secs < 120 { ( egui::Color32::from_rgb(200, 140, 0), format!("🟡 zuletzt: vor {secs} s"), ) } else { ( egui::Color32::DARK_RED, format!("🔴 zuletzt: vor {} s", secs), ) }; ui.colored_label(color, text); } fn handle_action(&mut self, action: UiAction) { match action { UiAction::SaveConfig => self.on_save_config(), UiAction::StartNew => self.on_start_new(), UiAction::Continue(id) => self.on_continue(id), UiAction::OpenWeb(id) => self.on_open_web(id), UiAction::Stop => self.on_stop(), UiAction::DismissError => self.state = AppState::Idle, } } } fn spawn_background( runtime: &Runtime, config: Config, pending_dir: PathBuf, http: Arc, case_store: Arc, snapshot_cache: Arc, ) -> (Uploader, mpsc::UnboundedReceiver, OnelinerPoller) { let _guard = runtime.enter(); let poll_interval = config.poll_interval(); let window_hours = config.window_hours(); let (uploader, rx) = Uploader::spawn(config.clone(), pending_dir); let poller = OnelinerPoller::spawn( http, PollerConfig { server_url: config.server_url, api_key: config.api_key, poll_interval, window_hours, }, case_store, snapshot_cache, ); (uploader, rx, poller) } fn parse_rfc3339(s: &str) -> Option { time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok() } /// Extract `HH:MM` from an RFC3339 UTC timestamp. Returns the raw string /// truncated to the first 16 chars as a fallback on parse failure — the /// user still gets something readable. fn extract_hhmm(ts: &str) -> String { match parse_rfc3339(ts) { Some(t) => { let local = t .to_offset( time::UtcOffset::current_local_offset() .unwrap_or(time::UtcOffset::UTC), ); format!("{:02}:{:02}", local.hour(), local.minute()) } None => ts.chars().take(16).collect(), } } impl eframe::App for DoctateApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.drain_recorder_events(); self.drain_upload_events(); let mut pending_action: Option = None; egui::TopBottomPanel::top("header").show(ctx, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("doctate"); ui.label(self.state.label()); }); ui.add_space(4.0); }); egui::TopBottomPanel::bottom("footer").show(ctx, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| { self.render_staleness(ui); }); ui.add_space(4.0); }); egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add_space(8.0); if let Some(a) = self.render_controls(ui) { pending_action = Some(a); } ui.add_space(8.0); }); // Case list fills the remaining space. if !matches!(self.state, AppState::NotConfigured) && let Some(a) = self.render_case_list(ui) { pending_action = Some(a); } }); if let Some(action) = pending_action { self.handle_action(action); } // Repaint periodically so the staleness indicator + watch // snapshots reach the UI without a user event. match self.state { AppState::Recording { .. } => ctx.request_repaint_after(Duration::from_millis(250)), AppState::FinalizingRecording { .. } | AppState::Uploading { .. } => { ctx.request_repaint_after(Duration::from_millis(100)); } _ => ctx.request_repaint_after(Duration::from_secs(1)), } } }