use eframe::egui::{self, Color32}; use serde_json::Value; use std::{ fs, path::{Path, PathBuf}, process::Command, sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant}, }; mod diagnostics; mod inventory; mod report; mod wifi; mod workflow; use workflow::{Identity, Inspection, Outcome, Settings}; const BUILD: &str = env!("CSR_BUILD_ID"); const GREEN: Color32 = Color32::from_rgb(70, 218, 168); const ORANGE: Color32 = Color32::from_rgb(246, 188, 101); fn tr(en: bool, de: &'static str, english: &'static str) -> &'static str { if en { english } else { de } } #[derive(Clone, Copy, PartialEq)] enum Page { Intake, Inspection, Repairs, Reports, Queue, Settings, } enum Work { Scan(Result<(PathBuf, Value), String>), Diagnostic(String, diagnostics::Diagnostic), Queue(Result, String>), Sync(Result), InventoryCheck(Result), Station(String, Result), Power(Result<(), String>), } #[derive(Default)] struct Interactive { key: String, colors_seen: Vec, color: usize, keys: Vec, moves: u64, clicks: u64, scrolls: u64, } struct App { data: PathBuf, page: Page, asset: String, identity: Identity, hardware_id: String, duplicates: Vec, known: Vec, inspection: Option, settings: Settings, settings_draft: Settings, status: String, receiver: Option>, wifi: wifi::Wifi, settings_wifi: wifi::Wifi, interactive: Option, queue: Vec, auto_sync: bool, next_sync: Instant, repair_key: String, repair_text: String, export_path: Option, dirty: bool, last_edit: Instant, storage_error: bool, station_device: Option, boot_pending: bool, flush_pending: bool, intake_upload_pending: bool, inspection_upload_pending: bool, automatic_scan: bool, station_ready: bool, next_station_attempt: Instant, } fn scanner_path() -> PathBuf { if let Some(p) = std::env::var_os("CSR_SCANNER") { return PathBuf::from(p); } // Prefer the scanner beside this build, then the installed live-system scanner. if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { let sibling = parent.join("pc-intake"); if sibling.exists() { return sibling; } } } let local = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scanner/target/release/pc-intake"); if local.exists() { local } else { PathBuf::from("/usr/local/bin/pc-intake") } } fn setup(data: &Path) -> Result<(), String> { for dir in [ "database", "reports", "checklists", "config", "inspections", "final-reports", ] { fs::create_dir_all(data.join(dir)).map_err(|e| e.to_string())?; } workflow::atomic_write(&data.join(".write-test"), b"ok")?; fs::remove_file(data.join(".write-test")).map_err(|e| e.to_string())?; workflow::init_index(data) } fn label(key: &str, en: bool) -> String { match key { "display" => tr(en, "Bildschirm", "Display"), "keyboard" => tr(en, "Tastatur", "Keyboard"), "touchpad" => tr(en, "Touchpad", "Touchpad"), "wifi" => tr(en, "WLAN", "Wi-Fi"), "audio" => tr(en, "Lautsprecher", "Speakers"), "microphone" => tr(en, "Mikrofon", "Microphone"), "webcam" => tr(en, "Kamera", "Camera"), "usb_ports" => tr(en, "USB-Anschlüsse", "USB ports"), "charging" => tr(en, "Akku und Laden", "Battery and charging"), "thermals" => tr(en, "Temperaturen", "Thermals"), "stability" => tr(en, "Stabilität", "Stability"), _ => { return format!( "{} {}", tr(en, "Laufwerk", "Drive"), key.trim_start_matches("disk:") ) } } .into() } fn guidance(key: &str, en: bool) -> &'static str { match key { "display"=>tr(en,"Alle Farben auf Pixelfehler, Flecken und Flackern prüfen. Pfeiltasten wechseln die Farbe.","Inspect every colour for dead pixels, marks and flicker. Arrow keys change the colour."), "keyboard"=>tr(en,"Jede Taste drücken. Erkannte Tasten erscheinen im Test. Fn-Tasten zusätzlich manuell prüfen.","Press every key. Recognised keys appear in the test. Check Fn keys manually too."), "touchpad"=>tr(en,"Im Testfeld nur das Touchpad verwenden. Bewegung, beide Tasten und Scrollen prüfen. Mausereignisse beweisen keine Touchpad-Funktion.","Use only the touchpad inside the test area. Check movement, both buttons and scrolling. Mouse events alone do not prove the touchpad works."), "wifi"=>tr(en,"LAN-Kabel abziehen, WLAN wählen, verbinden und Datenübertragung testen.","Unplug Ethernet, select Wi-Fi, connect and test data transfer."), "audio"=>tr(en,"Linken und rechten Kanal anhören. Beide müssen klar und ohne Verzerrung sein.","Listen to the left and right channels. Both must be clear without distortion."), "microphone"=>tr(en,"Einen Satz sprechen, danach die Wiedergabe auf Lautstärke und Klarheit prüfen.","Speak a sentence, then check the playback for volume and clarity."), "webcam"=>tr(en,"Im Vorschaufenster Bild, Schärfe und Aussetzer prüfen. Fenster schließt nach der eingestellten Dauer.","Check the preview for an image, focus and dropouts. It closes after the configured duration."), "usb_ports"=>tr(en,"Erste Geräteliste erfassen. Einen bekannten USB-Stick in jeden Anschluss stecken und jeweils erneut prüfen. Änderungen werden angezeigt; Anschluss in den Notizen festhalten.","Capture the initial device list. Insert a known USB device into each port and rerun. Changes are shown; record each port in the notes."), "charging"=>tr(en,"Mit Netzteil messen, danach ohne Netzteil erneut messen. Ladezustand und Betrieb prüfen. Akkugesundheit steht im Scanbericht.","Measure with the charger connected, then unplug it and rerun. Check charging state and operation. Battery health appears in the scan report."), "thermals"=>tr(en,"Lasttest starten und Lüfter beobachten. Messwerte und Werkstattgrenze prüfen. Hohe Temperatur erfordert Prüfung, nicht automatisch einen Hardwaredefekt.","Run the load test and observe the fans. Review measurements against the workshop threshold. High temperature needs investigation, not an automatic hardware-failure verdict."), "stability"=>tr(en,"CPU-/RAM-Test starten. Auf Absturz, Neustart und Fehler achten. Dies ersetzt keinen vollständigen Offline-RAM-Test.","Run the CPU/RAM test. Watch for crashes, resets and errors. This does not replace a complete offline memory test."), _=>tr(en,"SMART-Daten dieses Laufwerks lesen und Warnungen prüfen. Der Test verändert keine Laufwerksdaten.","Read this drive's SMART data and review warnings. The test does not modify drive data."), } } impl App { fn new(data: PathBuf) -> Self { let init = setup(&data); let loaded = Settings::load(&data); let status = match (&init, &loaded) { (Err(e), _) | (_, Err(e)) => format!("Storage/settings error / Speicherfehler: {e}"), _ => "Ready / Bereit".into(), }; let settings = loaded.unwrap_or_default(); let known = workflow::known_assets(&data); let identity = Identity::detect(); let duplicates = workflow::duplicates(&data, "", &identity); let identification = inventory::identify(&identity); let hardware_id = identification .as_ref() .map(|i| i.hwid.clone()) .unwrap_or_default(); let auto_asset = duplicates .first() .cloned() .unwrap_or_else(|| identification.map(|i| i.local_id).unwrap_or_default()); let mut wifi = wifi::Wifi::default(); wifi.set_test_url(&settings.test_url); let flush_pending = fs::read_dir(data.join("outbox/inspections")) .map(|mut it| it.next().is_some()) .unwrap_or(false); let boot_pending = data.join("config/inventory.json").exists() && !hardware_id.is_empty(); let mut settings_wifi = wifi::Wifi::default(); settings_wifi.configure_saved(&data); settings_wifi.reconnect_saved(); Self { data, page: Page::Intake, asset: auto_asset, identity, hardware_id, duplicates, known, inspection: None, settings_draft: settings.clone(), settings, status, receiver: None, wifi, settings_wifi, interactive: None, queue: Vec::new(), auto_sync: false, next_sync: Instant::now(), repair_key: "display".into(), repair_text: String::new(), export_path: None, dirty: false, last_edit: Instant::now(), storage_error: init.is_err(), station_device: None, boot_pending, flush_pending, intake_upload_pending: false, inspection_upload_pending: false, automatic_scan: false, station_ready: false, next_station_attempt: Instant::now(), } } fn busy(&self) -> bool { self.receiver.is_some() || self.wifi.busy() || self.settings_wifi.busy() || self.interactive.is_some() } fn en(&self) -> bool { self.settings.language == "en" } fn changed(&mut self) { if let Some(s) = &mut self.inspection { if !s.sync_phase.is_empty() { s.sync_phase = "IN_PROGRESS".into(); } s.changed(); } self.dirty = true; self.last_edit = Instant::now(); self.export_path = None; } fn persist(&mut self) -> bool { if let Some(s) = &self.inspection { match s.save(&self.data) { Ok(()) => { self.dirty = false; self.storage_error = false; if !s.sync_phase.is_empty() { self.inspection_upload_pending = true; } true } Err(e) => { self.status = format!("Save failed / Speichern fehlgeschlagen: {e}"); self.storage_error = true; false } } } else { true } } fn open_asset(&mut self, resume: bool) { if self.busy() || !workflow::valid_id(&self.asset) { return; } if self.dirty && !self.persist() { return; } let path = self .data .join("inspections") .join(format!("{}.json", self.asset)); let result = if path.exists() { Inspection::load(&self.data, &self.asset) } else if resume { Ok(workflow::migrate( &self.data, &self.asset, self.settings.clone(), )) } else { Ok(Inspection::new( &self.asset, self.identity.clone(), self.settings.clone(), )) }; match result { Ok(mut s) => { if s.settings != self.settings { s.settings = self.settings.clone(); s.changed(); } self.repair_key = s.order.first().cloned().unwrap_or_else(|| "display".into()); self.repair_text.clear(); self.inspection = Some(s); self.export_path = None; self.wifi = wifi::Wifi::default(); self.wifi.set_test_url(&self.settings.test_url); if self.persist() { self.page = Page::Inspection; self.status = "Inspection loaded / Prüfung geladen".into(); } } Err(e) => self.status = e, } } fn start_scan(&mut self, full: bool) { if self.busy() || self.inspection.is_none() { return; } if let Some(s) = &mut self.inspection { s.changed(); } self.export_path = None; if !self.persist() { return; } let data = self.data.clone(); let asset = self.inspection.as_ref().unwrap().asset.clone(); let scanner = scanner_path(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.status = "Hardware scan running / Hardware-Scan läuft …".into(); thread::spawn(move || { let result = (|| { let mut command = Command::new("timeout"); command .args(["--kill-after=5s", "600s"]) .arg(scanner) .args(["--asset-id", &asset, "--output"]) .arg(data.join("reports")); command.arg(if full { "--compile" } else { "--quick" }); let out = command.output().map_err(|e| format!("Scanner: {e}"))?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).into_owned()); } workflow::latest_report(&data, &asset) .ok_or_else(|| "No valid report produced".into()) })(); let _ = tx.send(Work::Scan(result)); }); } fn start_test(&mut self, key: &str) { if self.busy() || self.inspection.is_none() { return; } let s = self.inspection.as_mut().unwrap(); s.settings = self.settings.clone(); s.record( key, "started", Outcome::NotTested, "Test started / Test gestartet".into(), ); self.export_path = None; if !self.persist() { return; } if ["display", "keyboard", "touchpad"].contains(&key) { self.interactive = Some(Interactive { key: key.into(), colors_seen: vec![0], ..Default::default() }); return; } let settings = self.settings.clone(); let key = key.to_owned(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.status = "Test running / Test läuft …".into(); thread::spawn(move || { let result = diagnostics::run(&key, &settings); let _ = tx.send(Work::Diagnostic(key, result)); }); } fn record_diagnostic(&mut self, key: &str, mut result: diagnostics::Diagnostic) { if let Some(s) = &mut self.inspection { if ["usb_ports", "charging"].contains(&key) { if let Some(previous) = s .checks .get(key) .and_then(|c| c.history.iter().rev().find(|e| e.kind == "diagnostic")) { let added: Vec<_> = result .text .lines() .filter(|line| !previous.text.lines().any(|p| p == *line)) .collect(); self.status = format!("Changed / Geändert: {}", added.join(" · ")); } } if result.text.len() > 100_000 { result .text .truncate(result.text.floor_char_boundary(100_000)); } s.build = BUILD.into(); s.record(key, "diagnostic", result.outcome, result.text); } self.export_path = None; self.persist(); } fn refresh_queue(&mut self) { if self.busy() { return; } let data = self.data.clone(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); thread::spawn(move || { let _ = tx.send(Work::Queue(inventory::queue(&data))); }); } fn upload(&mut self, retry: bool) { if self.busy() { return; } let data = self.data.clone(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.status = "Uploading / Upload läuft …".into(); thread::spawn(move || { let _ = tx.send(Work::Sync(inventory::sync(&data, retry))); }); } fn poll(&mut self) { self.settings_wifi.poll(); // Settings connectivity checks never become inspection results. let _ = self.settings_wifi.take_test_log(); self.wifi.poll(); if let Some(log) = self.wifi.take_test_log() { self.record_diagnostic( "wifi", diagnostics::Diagnostic { outcome: if log.contains("STATUS: FEHLER") { Outcome::SetupProblem } else { Outcome::NeedsReview }, text: log, }, ); } let event = self.receiver.as_ref().map(|r| r.try_recv()); match event { Some(Ok(work)) => { self.receiver = None; match work { Work::Scan(Ok((p, v))) => { if let Some(s) = &mut self.inspection { s.set_report(p, v); } self.persist(); self.known = workflow::known_assets(&self.data); self.duplicates = workflow::duplicates(&self.data, &self.asset, &self.identity); if !self.storage_error { self.status = "Scan saved / Scan gespeichert".into(); } if self.automatic_scan { self.automatic_scan = false; self.intake_upload_pending = true; self.next_station_attempt = Instant::now(); } else { self.refresh_queue(); } } Work::Scan(Err(e)) => { self.automatic_scan = false; self.status = format!("Scan failed / Scan fehlgeschlagen: {e}") } Work::Diagnostic(key, result) => { self.status = format!( "{}: {}", label(&key, self.en()), result.outcome.label(self.en()) ); self.record_diagnostic(&key, result); } Work::Queue(Ok(q)) => self.queue = q, Work::Queue(Err(e)) => self.status = e, Work::Power(result) => { if let Err(e) = result { self.status = e; } } Work::Station(action, result) => match result { Ok(result) => { self.flush_pending |= result["pending"].as_u64().unwrap_or(0) > 0; let device = result["device"].clone(); let intake = device["stage"] == "INTAKE"; self.station_device = Some(device); self.status = tr( self.en(), "Mit Inventory synchronisiert", "Synced with inventory", ) .into(); self.station_ready = action != "boot" || !intake; if action == "boot" && intake { self.automatic_scan = true; self.start_scan(false); } } Err(e) => { self.status = format!( "{}: {e}", tr( self.en(), "Offline / Synchronisierung ausstehend", "Offline / sync pending" ) ); self.next_station_attempt = Instant::now() + Duration::from_secs(20); match action.as_str() { "boot" => self.boot_pending = true, "intake" => self.intake_upload_pending = true, "flush" => self.flush_pending = true, _ => self.inspection_upload_pending = true, } } }, Work::InventoryCheck(result) => self.status = result.unwrap_or_else(|e| e), Work::Sync(result) => { self.status = result.unwrap_or_else(|e| e); self.next_sync = Instant::now() + Duration::from_secs(self.settings.retry_seconds); self.refresh_queue(); } } } Some(Err(mpsc::TryRecvError::Disconnected)) => { self.receiver = None; self.status = "Worker interrupted; retry / Aktion unterbrochen; erneut versuchen".into(); } _ => {} } if self.dirty && self.last_edit.elapsed() > Duration::from_millis(400) { self.persist(); } if !self.busy() && !self.dirty && Instant::now() >= self.next_station_attempt { if self.boot_pending { self.boot_pending = false; if self.inspection.is_none() { self.open_asset(true); self.page = Page::Intake; } self.station_action("boot"); } else if self.intake_upload_pending { self.intake_upload_pending = false; self.station_action("intake"); } else if self.flush_pending { self.flush_pending = false; self.station_action("flush"); } else if self.inspection_upload_pending { self.inspection_upload_pending = false; let phase = self .inspection .as_ref() .map(|s| s.sync_phase.as_str()) .unwrap_or(""); let action = match phase { "STARTED" => "start", "COMPLETE" => "complete", _ => "progress", }; self.station_action(action); } } if self.auto_sync && !self.busy() && Instant::now() >= self.next_sync { self.next_sync = Instant::now() + Duration::from_secs(self.settings.retry_seconds); self.upload(false); } } fn station_action(&mut self, action: &str) { if self.busy() { return; } let data = self.data.clone(); let path = if action == "intake" { self.inspection.as_ref().and_then(|s| s.report_path.clone()) } else if ["start", "progress", "complete"].contains(&action) { self.inspection .as_ref() .map(|s| data.join("inspections").join(format!("{}.json", s.asset))) } else { None }; let action = action.to_owned(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.status = tr( self.en(), "Inventory wird synchronisiert …", "Syncing inventory …", ) .into(); thread::spawn(move || { let result = inventory::station(&data, &action, path.as_deref()); let _ = tx.send(Work::Station(action, result)); }); } fn begin_inspection(&mut self) { if self.busy() { return; } if self.inspection.is_none() { self.open_asset(true); } if let Some(s) = &mut self.inspection { if s.sync_phase.is_empty() { s.sync_phase = "STARTED".into(); s.changed(); } } self.persist(); self.page = Page::Inspection; if self.inspection.as_ref().is_some_and(|s| s.report.is_none()) { self.start_scan(false); } } fn intake_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); ui.add_space(12.0); ui.heading(tr(en, "Gerät bereitmachen", "Prepare this device")); ui.label(tr( en, "Erkennen → Erfassen → Weiter zum nächsten Gerät oder prüfen", "Identify → Capture → Move to the next device or inspect", )); ui.add_space(24.0); ui.columns(2, |cols| { egui::Frame::group(cols[0].style()) .inner_margin(20.0) .show(&mut cols[0], |ui| { ui.small(tr(en, "DIESES GERÄT", "THIS DEVICE")); ui.heading(format!( "{} {}", self.identity.manufacturer, self.identity.model )); if let Some(d) = &self.station_device { ui.heading(format!("#{}", d["number"])); match d["_registration"].as_str() { Some("NEW") => { ui.colored_label(GREEN, tr(en, "Neu im Inventory registriert", "Newly registered in inventory")); } Some("EXISTING") => { ui.label(tr(en, "Bereits im Inventory vorhanden", "Existing inventory device")); } _ => {} } let stage = match d["stage"].as_str().unwrap_or("") { "INTAKE" => "Eingang", "INSPECTION" => "Inspektion", "REPAIR" => "Reparatur", "STORAGE" => "Lagerung", "ISSUED" => "Ausgegeben", _ => "—", }; ui.label(format!("{}: {stage}", tr(en, "Arbeitsschritt", "Stage"))); ui.label(format!( "Status: {}", d["status"]["label"].as_str().unwrap_or("—") )); ui.add_space(16.0); ui.label(format!("RAM: {} GB", d["ram_gb"])); ui.label(format!("SSD: {} GB · HDD: {} GB", d["ssd_gb"], d["hdd_gb"])); } else { ui.label(tr( en, "Inventory-Zuordnung ausstehend", "Waiting for inventory lookup", )); } ui.add_space(12.0); egui::CollapsingHeader::new(tr(en, "Hardware-Kennung", "Hardware identity")) .show(ui, |ui| { ui.small(&self.hardware_id); ui.small(&self.identity.serial); }); }); egui::Frame::group(cols[1].style()) .inner_margin(20.0) .show(&mut cols[1], |ui| { ui.small(tr(en, "NÄCHSTER SCHRITT", "NEXT STEP")); if self.busy() { ui.spinner(); } ui.label(if self.station_ready { tr( en, "Gerätedaten synchronisiert.", "Device information synced.", ) } else { tr( en, "Neue Geräte werden automatisch erfasst und synchronisiert.", "New devices are captured and synced automatically.", ) }); ui.add_space(20.0); let allowed_stage = self .station_device .as_ref() .map(|d| { matches!( d["stage"].as_str(), Some("INTAKE" | "INSPECTION" | "REPAIR") ) }) .unwrap_or(true); if ui .add_enabled( !self.busy() && !self.hardware_id.is_empty() && allowed_stage, egui::Button::new(tr( en, "Prüfung starten / fortsetzen", "Start / resume inspection", )) .min_size(egui::vec2(ui.available_width(), 48.0)), ) .clicked() { self.begin_inspection(); } ui.add_space(12.0); if ui .add_enabled( !self.busy() && self.station_ready && !self.inspection_upload_pending && !self.intake_upload_pending, egui::Button::new(tr( en, "Fertig — herunterfahren", "Done — shut down", )) .min_size(egui::vec2(ui.available_width(), 48.0)), ) .clicked() && self.persist() { let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); thread::spawn(move || { let result = Command::new("systemctl") .arg("poweroff") .output() .map_err(|e| e.to_string()) .and_then(|o| { if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).into_owned()) } }); let _ = tx.send(Work::Power(result)); }); } ui.add_space(16.0); if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending { ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben auf diesem USB gespeichert.","Not fully synced yet. Results remain saved on this USB.")); if ui.add_enabled(!self.busy(),egui::Button::new(tr(en,"Offline speichern und herunterfahren","Save offline and shut down"))).clicked() && self.persist() { let data=self.data.clone(); let inspection=self.inspection.as_ref().filter(|s|!s.sync_phase.is_empty()).map(|s|data.join("inspections").join(format!("{}.json",s.asset))); let (tx,rx)=mpsc::channel();self.receiver=Some(rx); thread::spawn(move || { let result=(|| { if let Some(path)=inspection {inventory::station(&data,"queue",Some(&path))?;} let output=Command::new("systemctl").arg("poweroff").output().map_err(|e|e.to_string())?; if output.status.success(){Ok(())}else{Err(String::from_utf8_lossy(&output.stderr).into_owned())} })();let _=tx.send(Work::Power(result)); }); } } if ui .add_enabled( !self.busy(), egui::Button::new(tr( en, "Verbindung erneut prüfen", "Retry connection", )), ) .clicked() { self.boot_pending = true; self.next_station_attempt = Instant::now(); } if ui .button(tr(en, "WLAN / Einstellungen", "Wi-Fi / settings")) .clicked() { self.page = Page::Settings; } if self.hardware_id.is_empty() { ui.colored_label( ORANGE, tr( en, "Keine gültige Hardware-Kennung. Aktuellen USB neu starten.", "No valid hardware identity. Reboot the updated USB.", ), ); } }); }); } fn inspection_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); let Some(s) = self.inspection.as_ref() else { ui.label(tr(en, "Zuerst ein Gerät erfassen.", "Open a device first.")); return; }; let conflict = self.identity.conflicts(&s.identity); let has_report = s.report.is_some(); let asset = s.asset.clone(); let index = s.step.min(s.order.len()); let len = s.order.len(); let key = s.order.get(index).cloned(); ui.heading(format!( "{} · {}", tr(en, "Prüfung", "Inspection"), self.station_device .as_ref() .and_then(|d| d["number"].as_u64()) .map(|n| format!("#{n}")) .unwrap_or(asset) )); if self .inspection .as_ref() .is_some_and(|s| s.sync_phase.is_empty()) { if ui .add_enabled( !self.busy(), egui::Button::new(tr(en, "Prüfung starten", "Start inspection")), ) .clicked() { self.begin_inspection(); } return; } if conflict { ui.colored_label(ORANGE,tr(en,"Dieser Bericht gehört zu anderer Hardware. Historie kann gelesen werden; Live-Tests sind gesperrt.","This record belongs to different hardware. History is available; live tests are disabled.")); } if !has_report { ui.label(tr(en,"Zuerst Hardware erfassen. Modell, Seriennummer und Laufwerke werden automatisch erkannt.","First scan the hardware. Model, serial number and drives are detected automatically.")); } if ui .add_enabled( !self.busy() && !conflict, egui::Button::new(if has_report { tr(en, "Hardware erneut erfassen", "Rescan hardware") } else { tr(en, "Hardware erfassen", "Scan hardware") }), ) .clicked() { self.start_scan(false); } if ui .add_enabled( !self.busy() && !conflict, egui::Button::new(tr( en, "Vollständiger Scan mit CPU-/RAM-/Compile-Benchmarks", "Full scan with CPU/RAM/compile benchmarks", )), ) .clicked() { self.start_scan(true); } if !has_report { return; } ui.add( egui::ProgressBar::new(index as f32 / len.max(1) as f32).text(format!( "{} / {}", index + 1, len + 1 )), ); if let Some(key) = key { ui.heading(label(&key, en)); ui.label(guidance(&key, en)); let c = self.inspection.as_ref().unwrap().checks[&key].clone(); if !c.suggestion.is_empty() { ui.colored_label(ORANGE, &c.suggestion); } if key == "microphone" || key == "webcam" { ui.label(format!( "{} s", if key == "microphone" { self.settings.recording_seconds } else { self.settings.camera_seconds } )); } if key == "thermals" || key == "stability" { ui.label(format!( "{} s · {} °C", self.settings.stress_seconds, self.settings.max_temperature_c )); } ui.add_enabled_ui(!conflict, |ui| { if key == "wifi" { if diagnostics::needs_lan_disconnect() { ui.colored_label( ORANGE, tr( en, "LAN-Kabel entfernen: Bei diesem mobilen Gerät muss der WLAN-Test ohne aktive Kabelverbindung erfolgen.", "Disconnect Ethernet: test Wi-Fi on this mobile device without an active wired connection.", ), ); } self.wifi.set_language(en); let was_busy = self.wifi.busy(); self.wifi.ui(ui, self.receiver.is_some()); if !was_busy && self.wifi.busy() { if let Some(s) = &mut self.inspection { s.settings = self.settings.clone(); s.record( "wifi", "started", Outcome::NotTested, "Wi-Fi action started / WLAN-Aktion gestartet".into(), ); } self.export_path = None; self.persist(); } } else if ui .add_enabled( !self.busy(), egui::Button::new(tr( en, "Test starten / erneut testen", "Start test / retry", )), ) .clicked() { self.start_test(&key); } }); if !c.evidence.is_empty() { egui::CollapsingHeader::new(tr( en, "Beobachtete Messwerte / Ausgabe", "Observed measurements / output", )) .default_open(false) .show(ui, |ui| { ui.add( egui::TextEdit::multiline(&mut c.evidence.clone()) .desired_rows(6) .desired_width(f32::INFINITY) .interactive(false), ); }); } ui.separator(); ui.label(tr( en, "Ergebnis nach Prüfung bestätigen. Fehlendes Werkzeug ist kein Hardwaredefekt.", "Confirm the result after inspection. A missing tool is not a hardware failure.", )); let old = c.outcome; let mut chosen = old; ui.add_enabled_ui(!self.busy(), |ui| { ui.horizontal_wrapped(|ui| { for outcome in [Outcome::Pass, Outcome::HardwareFailure] { ui.selectable_value(&mut chosen, outcome, outcome.label(en)); } egui::ComboBox::from_id_salt("other-result") .selected_text(tr(en, "Weiteres Ergebnis", "Other result")) .show_ui(ui, |ui| { for outcome in Outcome::ALL { ui.selectable_value(&mut chosen, outcome, outcome.label(en)); } }); }); }); if chosen != old { let s = self.inspection.as_mut().unwrap(); let notes = s.checks[&key].notes.clone(); s.record(&key, "technician_result", chosen, notes); self.export_path = None; self.persist(); } ui.label(tr( en, "Notizen zu diesem Test (automatisch gespeichert)", "Test notes (saved automatically)", )); if ui .add( egui::TextEdit::multiline( &mut self .inspection .as_mut() .unwrap() .checks .get_mut(&key) .unwrap() .notes, ) .desired_rows(3) .desired_width(f32::INFINITY), ) .changed() { self.changed(); } egui::CollapsingHeader::new(tr(en, "Versuche und Reparaturen", "Attempts and repairs")) .show(ui, |ui| { for e in c.history.iter().rev() { ui.label(format!( "{} · {} · {}", e.timestamp, e.kind, e.outcome.label(en) )); ui.label(&e.text); ui.separator(); } }); } else { self.approval_ui(ui); } ui.separator(); ui.add_enabled_ui(!self.busy(), |ui| { ui.horizontal_wrapped(|ui| { if ui .add_enabled(index > 0, egui::Button::new(tr(en, "← Zurück", "← Back"))) .clicked() { self.inspection.as_mut().unwrap().step = index - 1; self.persist(); } if ui .add_enabled(index < len, egui::Button::new(tr(en, "Weiter →", "Next →"))) .clicked() { self.inspection.as_mut().unwrap().step = index + 1; self.persist(); } if ui .button(tr(en, "Zur Zusammenfassung", "Go to summary")) .clicked() { self.inspection.as_mut().unwrap().step = len; self.persist(); } }); }); } fn approval_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); ui.heading(tr( en, "Zusammenfassung und Freigabe", "Summary and approval", )); let s = self.inspection.as_ref().unwrap(); for k in &s.order { ui.label(format!( "{}: {}", label(k, en), s.checks[k].outcome.label(en) )); } let blockers = s.blockers(); for b in &blockers { ui.colored_label(ORANGE, b); } ui.label(tr(en, "Technikername", "Technician name")); if ui .text_edit_singleline(&mut self.inspection.as_mut().unwrap().technician) .changed() { self.changed(); } ui.label(tr(en, "Gesamtnotizen", "Overall notes")); if ui .add( egui::TextEdit::multiline(&mut self.inspection.as_mut().unwrap().notes) .desired_rows(3), ) .changed() { self.changed(); } let complete = self.inspection.as_ref().is_some_and(|s| { !s.technician.trim().is_empty() && s.checks .values() .all(|c| !matches!(c.outcome, Outcome::NotTested | Outcome::NeedsReview)) }); if ui .add_enabled( complete && !self.busy(), egui::Button::new(tr( en, "Prüfung abschließen und synchronisieren", "Complete inspection and sync", )), ) .clicked() { let s = self.inspection.as_mut().unwrap(); s.sync_phase = "COMPLETE".into(); s.changed(); self.persist(); self.next_station_attempt = Instant::now(); self.page = Page::Intake; } let can_approve = blockers.is_empty() && !self.busy(); if ui .add_enabled( can_approve, egui::Button::new(tr(en, "Gerät ausdrücklich freigeben", "Approve device")), ) .clicked() { match self.inspection.as_mut().unwrap().approve() { Ok(()) => { self.persist(); } Err(e) => self.status = e, } } if let Some(a) = &self.inspection.as_ref().unwrap().approval { ui.colored_label( GREEN, format!( "{}: {}", tr(en, "Freigegeben durch", "Approved by"), a.technician ), ); } if ui .button(tr(en, "Druckbericht erstellen", "Create printable report")) .clicked() { self.export_report(); } } fn repairs_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); ui.heading(tr( en, "Reparaturen und Nachprüfung", "Repairs and retesting", )); let Some(s) = self.inspection.as_ref() else { ui.label(tr(en, "Zuerst ein Gerät öffnen.", "Open a device first.")); return; }; let order = s.order.clone(); egui::ComboBox::from_id_salt("repair-component") .selected_text(label(&self.repair_key, en)) .show_ui(ui, |ui| { for k in &order { ui.selectable_value(&mut self.repair_key, k.clone(), label(k, en)); } }); ui.label(tr( en, "Reparatur, Ersatzteil und Techniker dokumentieren", "Record the repair, replacement part and technician", )); ui.add(egui::TextEdit::multiline(&mut self.repair_text).desired_rows(4)); if ui .add_enabled( !self.busy() && !self.repair_text.trim().is_empty(), egui::Button::new(tr( en, "Reparatur speichern und Nachprüfung öffnen", "Save repair and open retest", )), ) .clicked() { let key = self.repair_key.clone(); let text = std::mem::take(&mut self.repair_text); let s = self.inspection.as_mut().unwrap(); s.record(&key, "repair", Outcome::NotTested, text); s.step = s.order.iter().position(|k| k == &key).unwrap_or(0); self.export_path = None; if self.persist() { self.page = Page::Inspection; } } let s = self.inspection.as_ref().unwrap(); for k in &s.order { let c = &s.checks[k]; if c.outcome == Outcome::HardwareFailure || !c.history.is_empty() { egui::CollapsingHeader::new(format!("{} · {}", label(k, en), c.outcome.label(en))) .show(ui, |ui| { for e in c.history.iter().rev() { ui.label(format!( "{} · {} · {}", e.timestamp, e.kind, e.outcome.label(en) )); ui.label(&e.text); } }); } } } fn export_report(&mut self) { if !self.persist() { return; } if self.busy() { self.status = "Wait for the running test / Laufenden Test abwarten".into(); return; } if let Some(s) = &self.inspection { match report::export(&self.data, s) { Ok(p) => { self.status = format!("Report / Bericht: {}", p.display()); self.export_path = Some(p); } Err(e) => self.status = e, } } } fn reports_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); ui.heading(tr(en, "Berichte", "Reports")); if self.inspection.is_none() { ui.label(tr( en, "Gerät unter Erfassung öffnen, um Bericht und Historie zu laden.", "Open a device in Intake to load its report and history.", )); return; } self.approval_ui(ui); if let Some(p) = self.export_path.clone() { ui.label(p.display().to_string()); if ui .button(tr( en, "Im Browser öffnen / drucken", "Open in browser / print", )) .clicked() { if let Err(e) = Command::new("xdg-open").arg(&p).spawn() { self.status = e.to_string(); } } ui.label(tr( en, "Im Browser Drucken oder Als PDF speichern wählen.", "Choose Print or Save as PDF in the browser.", )); } if let Some(s) = &self.inspection { if let Some(r) = &s.report { egui::CollapsingHeader::new(tr(en, "Hardwaredaten", "Hardware data")).show( ui, |ui| { ui.monospace(serde_json::to_string_pretty(r).unwrap_or_default()); }, ); } } } fn queue_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); ui.heading(tr(en, "Synchronisierung", "Synchronization")); let pending = fs::read_dir(self.data.join("outbox/inspections")) .map(|r| r.count()) .unwrap_or(0); ui.label(format!( "{}: {pending}", tr( en, "Ausstehende Prüfungsereignisse", "Pending inspection events" ) )); if ui .add_enabled( !self.busy() && pending > 0, egui::Button::new(tr( en, "Prüfungen jetzt synchronisieren", "Sync inspections now", )), ) .clicked() { self.flush_pending = true; self.next_station_attempt = Instant::now(); } ui.label(tr(en,"Berichte bleiben offline gespeichert. Der Server ordnet per HWID zu und vergibt neue Gerätenummern automatisch. Konfiguration: config/inventory.json und inventory-api-key.","Reports remain saved offline. The server matches hardware IDs and assigns new device numbers automatically. Configure config/inventory.json and inventory-api-key.")); ui.horizontal_wrapped(|ui| { if ui .add_enabled( !self.busy(), egui::Button::new(tr(en, "Aktualisieren", "Refresh")), ) .clicked() { self.refresh_queue(); } if ui .add_enabled( !self.busy(), egui::Button::new(tr( en, "Ausstehende jetzt hochladen / erneut versuchen", "Upload pending / retry now", )), ) .clicked() { self.upload(true); } }); if ui .checkbox( &mut self.auto_sync, tr( en, "Automatisch versuchen, solange diese App läuft", "Retry automatically while this app is running", ), ) .changed() { self.next_sync = Instant::now(); } for q in &self.queue { egui::Frame::group(ui.style()).show(ui, |ui| { ui.strong(format!( "{} · {} · {} {}", q.asset_id, q.status, q.attempts, tr(en, "Versuche", "attempts") )); if let Some(number) = q.inventory_number { ui.label(format!( "{}: {number}", tr(en, "Inventory-Gerätenummer", "Inventory device number") )); } if !q.error.is_empty() { ui.colored_label(ORANGE, &q.error); } ui.small(format!( "{} · Next retry UTC Unix: {}", q.report_path, q.next_attempt )); }); } if self.queue.is_empty() { ui.label(tr( en, "Keine Einträge. Aktualisieren sucht auch ältere Berichte.", "No entries. Refresh also discovers older reports.", )); } } fn settings_ui(&mut self, ui: &mut egui::Ui) { let en = self.en(); ui.heading(tr(en, "Werkstatteinstellungen", "Workshop settings")); ui.label(format!("Build: {BUILD}")); ui.label(self.data.display().to_string()); egui::CollapsingHeader::new(tr(en, "WLAN verbinden", "Connect Wi-Fi")) .default_open(true) .show(ui, |ui| { self.settings_wifi.set_language(en); self.settings_wifi.set_test_url(&self.settings.test_url); self.settings_wifi .ui(ui, self.receiver.is_some() || self.wifi.busy()); }); ui.separator(); ui.strong(tr(en, "Inventory-Verbindung", "Inventory connection")); let config = fs::read(self.data.join("config/inventory.json")) .ok() .and_then(|b| serde_json::from_slice::(&b).ok()); ui.label( config .as_ref() .and_then(|v| v.get("url")) .and_then(Value::as_str) .unwrap_or(tr( en, "Kein Inventory-Server konfiguriert", "No inventory server configured", )), ); if ui .add_enabled( !self.busy(), egui::Button::new(tr(en, "API-Verbindung prüfen", "Check API connection")), ) .clicked() { let data = self.data.clone(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.status = tr( en, "API-Verbindung wird geprüft …", "Checking API connection …", ) .into(); thread::spawn(move || { let _ = tx.send(Work::InventoryCheck(inventory::check(&data))); }); } ui.small(tr( en, "Prüft HTTPS und API-Schlüssel, ohne Berichte hochzuladen.", "Checks HTTPS and the API key without uploading reports.", )); ui.separator(); egui::CollapsingHeader::new(tr( en, "Weitere Werkstatteinstellungen", "Advanced workshop settings", )) .show(ui, |ui| { egui::ComboBox::from_id_salt("language") .selected_text(&self.settings_draft.language) .show_ui(ui, |ui| { ui.selectable_value(&mut self.settings_draft.language, "de".into(), "Deutsch"); ui.selectable_value(&mut self.settings_draft.language, "en".into(), "English"); }); ui.label(tr(en, "Tastaturbelegung", "Keyboard layout")); egui::ComboBox::from_id_salt("keyboard-layout") .selected_text(match self.settings_draft.keyboard_layout.as_str() { "us" => "English (US)", _ => "Deutsch (DE)", }) .show_ui(ui, |ui| { ui.selectable_value( &mut self.settings_draft.keyboard_layout, "de".into(), "Deutsch (DE)", ); ui.selectable_value( &mut self.settings_draft.keyboard_layout, "us".into(), "English (US)", ); }); ui.small(tr( en, "Die Tastaturbelegung wird beim nächsten Start des USB-Systems angewendet.", "The keyboard layout is applied on the next USB-system boot.", )); ui.label(tr(en, "WLAN-Testserver", "Wi-Fi test server")); ui.text_edit_singleline(&mut self.settings_draft.test_url); ui.horizontal(|ui| { ui.label(tr( en, "CPU-/RAM-Testdauer (Sekunden)", "CPU/RAM duration (seconds)", )); ui.add( egui::DragValue::new(&mut self.settings_draft.stress_seconds).range(10..=600), ); }); ui.horizontal(|ui| { ui.label(tr( en, "Mikrofonaufnahme (Sekunden)", "Microphone recording (seconds)", )); ui.add( egui::DragValue::new(&mut self.settings_draft.recording_seconds).range(1..=30), ); }); ui.horizontal(|ui| { ui.label(tr( en, "Kameravorschau (Sekunden)", "Camera preview (seconds)", )); ui.add( egui::DragValue::new(&mut self.settings_draft.camera_seconds).range(5..=120), ); }); ui.horizontal(|ui| { ui.label(tr(en, "Temperaturgrenze °C", "Temperature threshold °C")); ui.add( egui::DragValue::new(&mut self.settings_draft.max_temperature_c) .range(40.0..=110.0), ); }); ui.horizontal(|ui| { ui.label(tr( en, "Minimale Akkugesundheit %", "Minimum battery health %", )); ui.add( egui::DragValue::new(&mut self.settings_draft.min_battery_health_pct) .range(0..=100), ); }); ui.horizontal(|ui| { ui.label(tr( en, "Upload-Wiederholung (Sekunden)", "Upload retry interval (seconds)", )); ui.add( egui::DragValue::new(&mut self.settings_draft.retry_seconds).range(10..=3600), ); }); if ui .add_enabled( !self.busy(), egui::Button::new(tr(en, "Einstellungen speichern", "Save settings")), ) .clicked() { let result = self.settings_draft.validate().and_then(|_| { workflow::save_json( &self.data.join("config/workshop.json"), &self.settings_draft, ) }); match result { Ok(()) => { self.settings = self.settings_draft.clone(); self.wifi.set_test_url(&self.settings.test_url); if let Some(s) = &mut self.inspection { s.settings = self.settings.clone(); s.changed(); } self.persist(); if !self.storage_error { self.status = "Settings saved / Einstellungen gespeichert".into(); } } Err(e) => self.status = e, } } }); } fn display_ui(&mut self, ctx: &egui::Context) { let en = self.en(); let colors = [ Color32::BLACK, Color32::WHITE, Color32::RED, Color32::GREEN, Color32::BLUE, Color32::GRAY, Color32::YELLOW, ]; let test = self.interactive.as_mut().unwrap(); if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::Space)) { test.color = (test.color + 1) % colors.len(); } if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { test.color = (test.color + colors.len() - 1) % colors.len(); } if !test.colors_seen.contains(&test.color) { test.colors_seen.push(test.color); } let color = colors[test.color]; let ink = if matches!(test.color, 1 | 3 | 5 | 6) { Color32::BLACK } else { Color32::WHITE }; let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); egui::CentralPanel::default() .frame(egui::Frame::none().fill(color)) .show(ctx, |ui| { ui.vertical_centered(|ui| { ui.label(egui::RichText::new(format!("{} / 7", test.color + 1)).color(ink)); ui.label( egui::RichText::new(tr( en, "← / → oder Leertaste: Farbe wechseln · Esc: beenden", "← / → or Space: change colour · Esc: finish", )) .color(ink), ); if ui.button(tr(en, "Nächste Farbe", "Next colour")).clicked() { test.color = (test.color + 1) % colors.len(); } if ui.button(tr(en, "Test beenden", "Finish test")).clicked() { close = true; } }); }); if close { let test = self.interactive.take().unwrap(); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); self.record_diagnostic( "display", diagnostics::Diagnostic { outcome: Outcome::NeedsReview, text: format!("Colours viewed / Farben betrachtet: {:?}", test.colors_seen), }, ); } } fn interactive_ui(&mut self, ctx: &egui::Context) { if self.interactive.as_ref().map(|t| t.key.as_str()) == Some("display") { ctx.request_repaint(); return; } let en = self.en(); let test = self.interactive.as_mut().unwrap(); let key = test.key.clone(); let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); let colors = [ Color32::BLACK, Color32::WHITE, Color32::RED, Color32::GREEN, Color32::BLUE, Color32::GRAY, Color32::YELLOW, ]; egui::Window::new(label(&key, en)) .default_size([720.0, 500.0]) .collapsible(false) .show(ctx, |ui| { ui.label(guidance(&key, en)); if key == "display" { if ctx.input(|i| { i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::Space) }) { test.color = (test.color + 1) % colors.len(); } if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { test.color = (test.color + colors.len() - 1) % colors.len(); } ui.horizontal_wrapped(|ui| { for (i, c) in colors.iter().enumerate() { if ui .add(egui::Button::new(format!("{}", i + 1)).fill(*c)) .clicked() { test.color = i; } } }); if !test.colors_seen.contains(&test.color) { test.colors_seen.push(test.color); } let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width(), 320.0), egui::Sense::hover(), ); ui.painter().rect_filled(rect, 0.0, colors[test.color]); ui.label(format!("{} / 7", test.colors_seen.len())); } else if key == "keyboard" { for e in ctx.input(|i| i.events.clone()) { if let egui::Event::Key { key, pressed: true, .. } = e { let k = format!("{key:?}"); if !test.keys.contains(&k) { test.keys.push(k); } } } ui.label(test.keys.join(" · ")); } else { let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width(), 280.0), egui::Sense::click_and_drag(), ); ui.painter() .rect_stroke(rect, 8.0, egui::Stroke::new(2.0_f32, GREEN)); if response.hovered() { for e in ctx.input(|i| i.events.clone()) { match e { egui::Event::PointerMoved(p) => { test.moves += 1; ui.painter().circle_filled(p, 5.0, GREEN); } egui::Event::PointerButton { pressed: true, .. } => { test.clicks += 1 } egui::Event::MouseWheel { .. } => test.scrolls += 1, _ => {} } } } ui.label(format!( "Movement / Bewegung: {} · Clicks: {} · Scroll: {}", test.moves, test.clicks, test.scrolls )); } if ui .button(tr( en, "Test beenden und Beobachtung speichern", "Finish and save observations", )) .clicked() { close = true; } }); if close { let t = self.interactive.take().unwrap(); let text=match t.key.as_str(){"display"=>format!("Colours viewed / Farben betrachtet: {:?}",t.colors_seen),"keyboard"=>format!("Keys / Tasten: {}",t.keys.join(", ")),_=>format!("Pointer movements: {}; clicks: {}; scroll events: {}. Input source requires technician confirmation.",t.moves,t.clicks,t.scrolls)}; self.record_diagnostic( &t.key, diagnostics::Diagnostic { outcome: Outcome::NeedsReview, text, }, ); } } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { self.poll(); if self.interactive.as_ref().map(|t| t.key.as_str()) == Some("display") { self.display_ui(ctx); return; } let en = self.en(); ctx.set_visuals(egui::Visuals::dark()); if self.busy() || self.dirty || self.auto_sync || self.boot_pending || self.flush_pending || self.intake_upload_pending || self.inspection_upload_pending { ctx.request_repaint_after(Duration::from_millis(200)); } egui::TopBottomPanel::top("navigation").show(ctx, |ui| { ui.add_space(8.0); ui.horizontal(|ui| { ui.heading("CS-R"); ui.add_space(24.0); for (page, de, english) in [ (Page::Intake, "Gerät", "Device"), (Page::Inspection, "Prüfung", "Inspection"), (Page::Settings, "Einstellungen", "Settings"), ] { if ui .selectable_label(self.page == page, tr(en, de, english)) .clicked() && self.persist() { self.page = page; } } ui.menu_button(tr(en, "Mehr", "More"), |ui| { for (page, de, english) in [ (Page::Repairs, "Reparaturen", "Repairs"), (Page::Reports, "Berichte", "Reports"), (Page::Queue, "Synchronisierung", "Sync queue"), ] { if ui.button(tr(en, de, english)).clicked() && self.persist() { self.page = page; if page == Page::Queue { self.refresh_queue(); } ui.close_menu(); } } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.small(format!("v{}", env!("CARGO_PKG_VERSION"))); }); }); ui.add_space(8.0); }); egui::TopBottomPanel::bottom("status").show(ctx, |ui| { if self.busy() { ui.spinner(); } ui.colored_label( if self.storage_error { ORANGE } else { GREEN }, &self.status, ); if self.dirty { ui.label(tr( en, "Änderungen werden gespeichert …", "Saving changes …", )); } }); egui::CentralPanel::default().show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| match self.page { Page::Intake => self.intake_ui(ui), Page::Inspection => self.inspection_ui(ui), Page::Repairs => self.repairs_ui(ui), Page::Reports => self.reports_ui(ui), Page::Queue => self.queue_ui(ui), Page::Settings => self.settings_ui(ui), }); }); if self.interactive.is_some() { self.interactive_ui(ctx); } } fn on_exit(&mut self, _: Option<&eframe::glow::Context>) { self.persist(); } } fn main() -> eframe::Result<()> { let args: Vec = std::env::args().collect(); if args.iter().any(|a| a == "--version") { println!("CS-R Intake {} · {BUILD}", env!("CARGO_PKG_VERSION")); return Ok(()); } let data = args .iter() .position(|a| a == "--data") .and_then(|i| args.get(i + 1)) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/mnt/intake-data")); eframe::run_native( "CS-R Intake", eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1180.0, 800.0]) .with_min_inner_size([860.0, 600.0]), ..Default::default() }, Box::new(move |_| Ok(Box::new(App::new(data)))), ) } #[cfg(test)] mod tests { use super::*; #[test] fn layouts_for_all_pages_and_steps_stay_finite() { let root = std::env::temp_dir().join(format!("csr-ui-{}-{}", std::process::id(), workflow::now())); let mut app = App::new(root.clone()); let mut s = Inspection::new("PC-1", Identity::default(), Settings::default()); s.report = Some(serde_json::json!({})); s.sync_phase = "IN_PROGRESS".into(); app.inspection = Some(s); for language in ["de", "en"] { app.settings.language = language.into(); for width in [860.0, 1180.0] { let ctx = egui::Context::default(); for page in [ Page::Intake, Page::Inspection, Page::Repairs, Page::Reports, Page::Queue, Page::Settings, ] { app.page = page; for step in 0..=workflow::BASE.len() { app.inspection.as_mut().unwrap().step = step; let _ = ctx.run( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(width, 600.0), )), events: vec![egui::Event::PointerMoved(egui::pos2(300.0, 300.0))], ..Default::default() }, |ctx| { egui::CentralPanel::default().show(ctx, |ui| { match page { Page::Intake => app.intake_ui(ui), Page::Inspection => app.inspection_ui(ui), Page::Repairs => app.repairs_ui(ui), Page::Reports => app.reports_ui(ui), Page::Queue => app.queue_ui(ui), Page::Settings => app.settings_ui(ui), } assert!(ui.min_rect().is_finite()); }); }, ); } } } } fs::remove_dir_all(root).unwrap(); } } #[cfg(test)] mod fullscreen_tests { use super::*; #[test] fn colour_test_records_observations_and_returns_to_inspection() { let root = std::env::temp_dir().join(format!( "csr-colours-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let mut app = App::new(root.clone()); app.inspection = Some(Inspection::new( "PC-1", Identity::default(), Settings::default(), )); app.start_test("display"); let ctx = egui::Context::default(); for colour in 0..7 { app.interactive.as_mut().unwrap().color = colour; let _ = ctx.run( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(1180.0, 800.0), )), ..Default::default() }, |ctx| app.display_ui(ctx), ); } let _ = ctx.run( egui::RawInput { events: vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::default(), }], ..Default::default() }, |ctx| app.display_ui(ctx), ); assert!(app.interactive.is_none()); let saved = Inspection::load(&root, "PC-1").unwrap(); assert_eq!(saved.checks["display"].outcome, Outcome::NeedsReview); assert!(saved.checks["display"] .evidence .contains("0, 1, 2, 3, 4, 5, 6")); fs::remove_dir_all(root).unwrap(); } }