Files
doctate/client-desktop/src/app.rs
T
Brummel 7637596598 feat: Add poller and case list to desktop client
This commit introduces a new poller to the desktop client, responsible
for fetching case markers and their one-liners. It also integrates a
case list UI, allowing users to view and interact with their cases.

Key changes include:

- **New Dependencies**: Added `doctate-client-core`, `time`, and
  `webbrowser` to `Cargo.lock`.
- **Background Task Spawning**: Refactored `spawn_uploader` to
  `spawn_background` to include the new `OnelinerPoller` and its
  dependencies.
- **Case Store Integration**: The `DoctateApp` now initializes and uses
  a `CaseStore` to manage case markers.
- **UI Enhancements**:
    - A new `render_case_list` function displays recent cases with their
      last activity time and one-liner.
    - Buttons to "Continue" recording for a case and "Open" the case in
      a web browser are added.
    - A staleness indicator for the poller's connection is displayed.
- **State Management**:
    - The `AppState` is updated to reflect the new UI elements and
      interactions.
    - Actions like `Continue` and `OpenWeb` are handled.
- **Configuration**: Poll interval and window hours are now configurable
  and used by the poller.
- **Error Handling**: Improved error handling for saving configuration
  and background task operations.
- **Code Cleanup**: Minor refactoring and documentation updates.
2026-04-18 10:12:38 +02:00

647 lines
21 KiB
Rust

//! 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<Runtime>,
config: Option<Config>,
pending_dir: PathBuf,
state: AppState,
server_url_input: String,
api_key_input: String,
http_client: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_rx: watch::Receiver<Arc<Vec<CaseMarker>>>,
snapshot_cache: Arc<SnapshotCache>,
recorder: Option<Recorder>,
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
uploader: Option<Uploader>,
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
poller: Option<OnelinerPoller>,
}
enum UiAction {
SaveConfig,
StartNew,
Continue(Uuid),
OpenWeb(Uuid),
Stop,
DismissError,
}
impl DoctateApp {
pub fn new(
runtime: Arc<Runtime>,
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<UiAction> {
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<UiAction> {
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<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_cache: Arc<SnapshotCache>,
) -> (Uploader, mpsc::UnboundedReceiver<UploadEvent>, 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> {
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<UiAction> = 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)),
}
}
}