app.rs 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512
  1. use eframe::egui;
  2. use std::collections::HashMap;
  3. use std::sync::{mpsc, Arc};
  4. use tokio::sync::Mutex;
  5. use super::audits::AuditsView;
  6. use super::borrowing::BorrowingView;
  7. use super::categories::CategoriesView;
  8. use super::dashboard::DashboardView;
  9. use super::inventory::InventoryView;
  10. use super::issues::IssuesView;
  11. use super::label_templates::LabelTemplatesView;
  12. use super::login::LoginScreen;
  13. use super::printers::PrintersView;
  14. use super::ribbon::RibbonUI;
  15. use super::suppliers::SuppliersView;
  16. use super::templates::TemplatesView;
  17. use super::zones::ZonesView;
  18. use crate::api::ApiClient;
  19. use crate::config::AppConfig;
  20. use crate::models::{LoginResponse, UserInfo};
  21. use crate::session::{SessionData, SessionManager};
  22. pub struct BeepZoneApp {
  23. // Session management
  24. session_manager: Arc<Mutex<SessionManager>>,
  25. api_client: Option<ApiClient>,
  26. // Current view state
  27. current_view: AppView,
  28. previous_view: Option<AppView>,
  29. current_user: Option<UserInfo>,
  30. current_permissions: Option<serde_json::Value>,
  31. // Per-view filter state storage
  32. view_filter_states: HashMap<AppView, crate::core::components::filter_builder::FilterGroup>,
  33. // UI components
  34. login_screen: LoginScreen,
  35. dashboard: DashboardView,
  36. inventory: InventoryView,
  37. categories: CategoriesView,
  38. zones: ZonesView,
  39. borrowing: BorrowingView,
  40. audits: AuditsView,
  41. templates: TemplatesView,
  42. suppliers: SuppliersView,
  43. issues: IssuesView,
  44. printers: PrintersView,
  45. label_templates: LabelTemplatesView,
  46. ribbon_ui: Option<RibbonUI>,
  47. // Configuration
  48. #[allow(dead_code)]
  49. app_config: Option<AppConfig>,
  50. // State
  51. login_success: Option<(String, LoginResponse)>,
  52. show_about: bool,
  53. pub should_exit_to_kiosk: bool,
  54. // Kiosk integration
  55. pub is_kiosk_mode: bool,
  56. pub enable_full_osk_button: bool,
  57. pub show_osk: bool,
  58. pub osk_shift_mode: bool,
  59. last_focused_id: Option<egui::Id>,
  60. osk_event_queue: Vec<egui::Event>,
  61. // Status bar state
  62. server_status: ServerStatus,
  63. last_health_check: std::time::Instant,
  64. health_check_in_progress: bool,
  65. health_check_rx: Option<mpsc::Receiver<HealthCheckResult>>,
  66. // Re-authentication prompt state
  67. reauth_needed: bool,
  68. reauth_password: String,
  69. // Database outage tracking
  70. db_offline_latch: bool,
  71. last_timeout_at: Option<std::time::Instant>,
  72. consecutive_healthy_checks: u8,
  73. }
  74. #[derive(Debug, Clone, Copy, PartialEq)]
  75. pub enum ServerStatus {
  76. Unknown,
  77. Connected,
  78. Disconnected,
  79. Checking,
  80. }
  81. #[derive(Debug, Clone, Copy)]
  82. struct HealthCheckResult {
  83. status: ServerStatus,
  84. reauth_required: bool,
  85. }
  86. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  87. pub enum AppView {
  88. Login,
  89. Dashboard,
  90. Inventory,
  91. Categories,
  92. Zones,
  93. Borrowing,
  94. Audits,
  95. Templates,
  96. Suppliers,
  97. IssueTracker,
  98. Printers,
  99. LabelTemplates,
  100. }
  101. impl BeepZoneApp {
  102. pub fn new(
  103. _cc: &eframe::CreationContext<'_>,
  104. session_manager: Arc<Mutex<SessionManager>>,
  105. ) -> Self {
  106. let session_manager_blocking = session_manager.blocking_lock();
  107. let login_screen = LoginScreen::new(&session_manager_blocking);
  108. // Try to restore session on startup
  109. let (api_client, current_view, current_user, current_permissions) =
  110. if let Some(session) = session_manager_blocking.get_session() {
  111. log::info!("Found saved session, attempting to restore...");
  112. // Create API client with saved token
  113. match ApiClient::new(session.server_url.clone()) {
  114. Ok(mut client) => {
  115. client.set_token(session.token.clone());
  116. // Verify session is still valid (tolerant)
  117. match client.check_session_valid() {
  118. Ok(true) => {
  119. log::info!(
  120. "Session restored successfully for user: {}",
  121. session.user.username
  122. );
  123. (
  124. Some(client),
  125. AppView::Dashboard,
  126. Some(session.user.clone()),
  127. session.permissions.clone(),
  128. )
  129. }
  130. Ok(false) => {
  131. log::warn!("Saved session check returned invalid");
  132. (None, AppView::Login, None, None)
  133. }
  134. Err(e) => {
  135. log::warn!("Saved session validity check error: {}", e);
  136. // Be forgiving on startup: keep client and let periodic checks refine
  137. (
  138. Some(client),
  139. AppView::Dashboard,
  140. Some(session.user.clone()),
  141. session.permissions.clone(),
  142. )
  143. }
  144. }
  145. }
  146. Err(e) => {
  147. log::error!("Failed to create API client: {}", e);
  148. (None, AppView::Login, None, None)
  149. }
  150. }
  151. } else {
  152. log::info!("No saved session found");
  153. (None, AppView::Login, None, None)
  154. };
  155. drop(session_manager_blocking);
  156. // Load configuration and initialize ribbon UI
  157. let ribbon_ui = Some(RibbonUI::default());
  158. let app_config = None;
  159. let mut app = Self {
  160. session_manager,
  161. api_client,
  162. current_view,
  163. previous_view: None,
  164. view_filter_states: HashMap::new(),
  165. current_user,
  166. current_permissions,
  167. login_screen,
  168. dashboard: DashboardView::new(),
  169. inventory: InventoryView::new(),
  170. categories: CategoriesView::new(),
  171. zones: ZonesView::new(),
  172. borrowing: BorrowingView::new(),
  173. audits: AuditsView::new(),
  174. templates: TemplatesView::new(),
  175. suppliers: SuppliersView::new(),
  176. issues: IssuesView::new(),
  177. printers: PrintersView::new(),
  178. label_templates: LabelTemplatesView::new(),
  179. ribbon_ui,
  180. app_config,
  181. login_success: None,
  182. show_about: false,
  183. should_exit_to_kiosk: false,
  184. is_kiosk_mode: false,
  185. enable_full_osk_button: false,
  186. show_osk: false,
  187. osk_shift_mode: false,
  188. last_focused_id: None,
  189. osk_event_queue: Vec::new(),
  190. server_status: ServerStatus::Unknown,
  191. last_health_check: std::time::Instant::now(),
  192. health_check_in_progress: false,
  193. health_check_rx: None,
  194. reauth_needed: false,
  195. reauth_password: String::new(),
  196. db_offline_latch: false,
  197. last_timeout_at: None,
  198. consecutive_healthy_checks: 0,
  199. };
  200. // Do initial health check if we have an API client
  201. if app.api_client.is_some() {
  202. app.request_health_check();
  203. }
  204. app
  205. }
  206. pub fn handle_login_success(&mut self, server_url: String, response: LoginResponse) {
  207. // Ensure we have token and user
  208. let token = match response.token {
  209. Some(t) => t,
  210. None => {
  211. log::error!("Login successful but no token returned");
  212. return;
  213. }
  214. };
  215. let user = match response.user {
  216. Some(u) => u,
  217. None => {
  218. log::error!("Login successful but no user returned");
  219. return;
  220. }
  221. };
  222. // Capture username for logging before moving fields out of response
  223. let username = user.username.clone();
  224. log::info!("Login successful for user: {}", username);
  225. // Create API client with token
  226. let mut api_client = match ApiClient::new(server_url.clone()) {
  227. Ok(client) => client,
  228. Err(e) => {
  229. log::error!("Failed to create API client: {}", e);
  230. // This shouldn't happen in normal operation, so just log and continue without client
  231. return;
  232. }
  233. };
  234. api_client.set_token(token.clone());
  235. self.api_client = Some(api_client.clone());
  236. self.current_user = Some(user.clone());
  237. // Fetch permissions
  238. let permissions = match api_client.get_permissions() {
  239. Ok(resp) => {
  240. if resp.success {
  241. log::info!("Permissions fetched successfully: {:?}", resp.permissions);
  242. Some(resp.permissions)
  243. } else {
  244. log::warn!("Failed to fetch permissions: {:?}", resp.error);
  245. None
  246. }
  247. }
  248. Err(e) => {
  249. log::warn!("Error fetching permissions: {}", e);
  250. None
  251. }
  252. };
  253. self.current_permissions = permissions.clone();
  254. // Save session (blocking is fine here, it's just writing a small JSON file)
  255. let session_data = SessionData {
  256. server_url,
  257. token,
  258. user,
  259. remember_server: true,
  260. remember_username: true,
  261. saved_username: self.current_user.as_ref().map(|u| u.username.clone()),
  262. permissions,
  263. default_printer_id: None,
  264. last_printer_id: None,
  265. };
  266. let mut session_manager = self.session_manager.blocking_lock();
  267. if let Err(e) = session_manager.save_session(session_data) {
  268. log::error!("Failed to save session: {}", e);
  269. }
  270. // Switch to dashboard
  271. self.current_view = AppView::Dashboard;
  272. self.reauth_needed = false;
  273. self.reauth_password.clear();
  274. // Load dashboard data
  275. if let Some(client) = self.api_client.as_ref() {
  276. self.dashboard.refresh_data(client);
  277. }
  278. }
  279. fn handle_reauth_success(&mut self, server_url: String, response: LoginResponse) {
  280. // Ensure we have token and user
  281. let token = match response.token {
  282. Some(t) => t,
  283. None => {
  284. log::error!("Reauth successful but no token returned");
  285. return;
  286. }
  287. };
  288. let user = match response.user {
  289. Some(u) => u,
  290. None => {
  291. log::error!("Reauth successful but no user returned");
  292. return;
  293. }
  294. };
  295. // Preserve current view but refresh token and user
  296. let mut new_client = match ApiClient::new(server_url.clone()) {
  297. Ok(client) => client,
  298. Err(e) => {
  299. log::error!("Failed to create API client during reauth: {}", e);
  300. self.reauth_needed = true;
  301. return;
  302. }
  303. };
  304. new_client.set_token(token.clone());
  305. // Replace client and user
  306. self.api_client = Some(new_client.clone());
  307. self.current_user = Some(user.clone());
  308. // Fetch permissions
  309. let permissions = match new_client.get_permissions() {
  310. Ok(resp) => {
  311. if resp.success {
  312. Some(resp.permissions)
  313. } else {
  314. log::warn!("Failed to fetch permissions: {:?}", resp.error);
  315. None
  316. }
  317. }
  318. Err(e) => {
  319. log::warn!("Error fetching permissions: {}", e);
  320. None
  321. }
  322. };
  323. self.current_permissions = permissions.clone();
  324. // Save updated session
  325. let session_data = SessionData {
  326. server_url,
  327. token,
  328. user,
  329. remember_server: true,
  330. remember_username: true,
  331. saved_username: self.current_user.as_ref().map(|u| u.username.clone()),
  332. permissions,
  333. default_printer_id: None,
  334. last_printer_id: None,
  335. };
  336. let mut session_manager = self.session_manager.blocking_lock();
  337. if let Err(e) = session_manager.save_session(session_data) {
  338. log::error!("Failed to save session after reauth: {}", e);
  339. }
  340. }
  341. fn show_top_bar(&mut self, ctx: &egui::Context, disable_actions: bool) {
  342. egui::TopBottomPanel::top("top_bar")
  343. .exact_height(45.0)
  344. .show_separator_line(false)
  345. .frame(
  346. egui::Frame::new()
  347. .fill(ctx.style().visuals.window_fill)
  348. .stroke(egui::Stroke::NONE)
  349. .inner_margin(egui::vec2(16.0, 5.0)),
  350. )
  351. .show(ctx, |ui| {
  352. // Horizontal layout for title and controls
  353. ui.horizontal(|ui| {
  354. ui.heading("BeepZone");
  355. ui.separator();
  356. // User info
  357. if let Some(user) = &self.current_user {
  358. ui.label(format!("User: {} ({})", user.username, user.role));
  359. ui.label(format!("Powah: {}", user.power));
  360. }
  361. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
  362. ui.add_enabled_ui(!disable_actions, |ui| {
  363. if ui.button("About").clicked() {
  364. self.show_about = true;
  365. }
  366. if self.is_kiosk_mode && self.enable_full_osk_button {
  367. let btn_text = if self.show_osk { "Hide Keyboard" } else { "Keyboard" };
  368. if ui.button(btn_text).clicked() {
  369. self.show_osk = !self.show_osk;
  370. }
  371. }
  372. if ui.button("Bye").clicked() {
  373. self.handle_logout();
  374. }
  375. });
  376. });
  377. });
  378. });
  379. }
  380. fn show_ribbon(&mut self, ctx: &egui::Context) -> Option<String> {
  381. let mut action_triggered = None;
  382. if let Some(ribbon_ui) = &mut self.ribbon_ui {
  383. let min_height = ribbon_ui.preferred_height();
  384. // Outer container panel with normal background
  385. egui::TopBottomPanel::top("ribbon_container")
  386. .min_height(min_height + 16.0)
  387. .max_height(min_height + 96.0)
  388. .show_separator_line(false)
  389. .frame(egui::Frame::new().fill(if ctx.style().visuals.dark_mode {
  390. ctx.style().visuals.panel_fill
  391. } else {
  392. // Darker background in light mode
  393. egui::Color32::from_rgb(210, 210, 210)
  394. }))
  395. .show(ctx, |ui| {
  396. ui.add_space(0.0);
  397. let side_margin: f32 = 16.0;
  398. let inner_pad: f32 = 8.0;
  399. ui.horizontal(|ui| {
  400. // Left margin
  401. ui.add_space(side_margin);
  402. // Remaining width after left margin
  403. let remaining = ui.available_width();
  404. // Leave room for right margin and inner padding on both sides of the frame
  405. let content_width = (remaining - side_margin - inner_pad * 2.0).max(0.0);
  406. // Custom ribbon background color based on theme
  407. let is_dark_mode = ctx.style().visuals.dark_mode;
  408. let ribbon_bg_color = if is_dark_mode {
  409. // Lighter gray for dark mode - more visible contrast
  410. egui::Color32::from_rgb(45, 45, 45)
  411. } else {
  412. // Lighter/white ribbon in light mode
  413. egui::Color32::from_rgb(248, 248, 248)
  414. };
  415. egui::Frame::new()
  416. .fill(ribbon_bg_color)
  417. .inner_margin(inner_pad)
  418. .corner_radius(6.0)
  419. .show(ui, |ui| {
  420. // Constrain to the computed content width so right margin remains
  421. ui.set_width(content_width);
  422. ui.scope(|ui| {
  423. ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
  424. action_triggered = ribbon_ui.show(ctx, ui, self.current_permissions.as_ref());
  425. });
  426. // Update current view based on active ribbon tab
  427. if let Some(view_name) = ribbon_ui.get_active_view() {
  428. self.current_view = match view_name.to_lowercase().as_str() {
  429. "dashboard" => AppView::Dashboard,
  430. "inventory" => AppView::Inventory,
  431. "categories" => AppView::Categories,
  432. "zones" => AppView::Zones,
  433. "borrowing" => AppView::Borrowing,
  434. "audits" => AppView::Audits,
  435. "item templates" => AppView::Templates,
  436. "templates" => AppView::Templates, // Backwards compat
  437. "suppliers" => AppView::Suppliers,
  438. "issues" | "issue_tracker" => AppView::IssueTracker,
  439. "printers" => AppView::Printers,
  440. "label templates" => AppView::LabelTemplates,
  441. _ => self.current_view,
  442. };
  443. }
  444. });
  445. // Right margin
  446. ui.add_space(side_margin);
  447. });
  448. ui.add_space(8.0);
  449. });
  450. } else {
  451. // Fallback to simple ribbon if config failed to load
  452. egui::TopBottomPanel::top("ribbon")
  453. .exact_height(38.0)
  454. .show_separator_line(false)
  455. .show(ctx, |ui| {
  456. ui.add_space(2.0);
  457. ui.horizontal_wrapped(|ui| {
  458. ui.selectable_value(
  459. &mut self.current_view,
  460. AppView::Dashboard,
  461. "Dashboard",
  462. );
  463. ui.selectable_value(
  464. &mut self.current_view,
  465. AppView::Inventory,
  466. "Inventory",
  467. );
  468. ui.selectable_value(
  469. &mut self.current_view,
  470. AppView::Categories,
  471. "Categories",
  472. );
  473. ui.selectable_value(&mut self.current_view, AppView::Zones, "Zones");
  474. ui.selectable_value(
  475. &mut self.current_view,
  476. AppView::Borrowing,
  477. "Borrowing",
  478. );
  479. ui.selectable_value(&mut self.current_view, AppView::Audits, "Audits");
  480. ui.selectable_value(
  481. &mut self.current_view,
  482. AppView::Templates,
  483. "Templates",
  484. );
  485. ui.selectable_value(
  486. &mut self.current_view,
  487. AppView::Suppliers,
  488. "Suppliers",
  489. );
  490. ui.selectable_value(
  491. &mut self.current_view,
  492. AppView::IssueTracker,
  493. "Issues",
  494. );
  495. });
  496. });
  497. }
  498. action_triggered
  499. }
  500. pub fn handle_logout(&mut self) {
  501. log::info!("Taking myself out");
  502. // Logout from API
  503. if let Some(api_client) = &self.api_client {
  504. let _ = api_client.logout();
  505. }
  506. // Clear session and reset login screen (do both while holding the lock once)
  507. {
  508. let mut session_manager = self.session_manager.blocking_lock();
  509. if let Err(e) = session_manager.clear_session() {
  510. log::error!("Failed to clear session: {}", e);
  511. }
  512. // Reset login screen while we still have the lock
  513. self.login_screen = LoginScreen::new(&session_manager);
  514. } // Lock is dropped here
  515. // Reset state
  516. self.api_client = None;
  517. self.current_user = None;
  518. self.current_view = AppView::Login;
  519. self.should_exit_to_kiosk = true;
  520. self.server_status = ServerStatus::Unknown;
  521. // Reset ribbon state to default
  522. if let Some(ribbon) = &mut self.ribbon_ui {
  523. ribbon.active_tab = "Dashboard".to_string();
  524. }
  525. }
  526. /// Force an immediate health check (used when timeout errors detected)
  527. pub fn force_health_check(&mut self) {
  528. self.last_health_check = std::time::Instant::now() - std::time::Duration::from_secs(10);
  529. self.request_health_check();
  530. }
  531. fn request_health_check(&mut self) {
  532. if self.api_client.is_none() || self.health_check_in_progress {
  533. return;
  534. }
  535. if let Some(client) = &self.api_client {
  536. let api_client = client.clone();
  537. let reauth_needed = self.reauth_needed;
  538. let (tx, rx) = mpsc::channel();
  539. self.health_check_rx = Some(rx);
  540. self.health_check_in_progress = true;
  541. // Only show "Checking..." if we aren't already connected to avoid UI flickering
  542. if !matches!(self.server_status, ServerStatus::Connected) {
  543. self.server_status = ServerStatus::Checking;
  544. }
  545. self.last_health_check = std::time::Instant::now();
  546. std::thread::spawn(move || {
  547. let result = Self::run_health_check(api_client, reauth_needed);
  548. let _ = tx.send(result);
  549. });
  550. }
  551. }
  552. fn desired_health_interval(&self, predicted_block: bool) -> f32 {
  553. if predicted_block || self.db_offline_latch {
  554. 0.75
  555. } else if matches!(self.server_status, ServerStatus::Connected) {
  556. 1.5
  557. } else {
  558. 2.5
  559. }
  560. }
  561. fn poll_health_check(&mut self) {
  562. if let Some(rx) = &self.health_check_rx {
  563. match rx.try_recv() {
  564. Ok(result) => {
  565. self.apply_health_result(result);
  566. self.health_check_rx = None;
  567. self.health_check_in_progress = false;
  568. self.last_health_check = std::time::Instant::now();
  569. }
  570. Err(mpsc::TryRecvError::Empty) => {}
  571. Err(mpsc::TryRecvError::Disconnected) => {
  572. log::warn!("Health check worker disconnected unexpectedly");
  573. self.health_check_rx = None;
  574. self.health_check_in_progress = false;
  575. }
  576. }
  577. }
  578. }
  579. fn apply_health_result(&mut self, result: HealthCheckResult) {
  580. if self.reauth_needed != result.reauth_required {
  581. if self.reauth_needed && !result.reauth_required {
  582. log::info!("Session valid again; clearing re-auth requirement");
  583. } else if !self.reauth_needed && result.reauth_required {
  584. log::info!("Session invalid/expired; prompting re-auth");
  585. }
  586. self.reauth_needed = result.reauth_required;
  587. }
  588. match result.status {
  589. ServerStatus::Disconnected => {
  590. self.db_offline_latch = true;
  591. self.last_timeout_at = Some(std::time::Instant::now());
  592. self.consecutive_healthy_checks = 0;
  593. }
  594. ServerStatus::Connected => {
  595. self.consecutive_healthy_checks = self.consecutive_healthy_checks.saturating_add(1);
  596. if self.db_offline_latch {
  597. let timeout_cleared = self
  598. .last_timeout_at
  599. .map(|t| t.elapsed() > std::time::Duration::from_secs(2))
  600. .unwrap_or(true);
  601. if timeout_cleared && self.consecutive_healthy_checks >= 2 {
  602. log::info!("Health checks stable; clearing database offline latch");
  603. self.db_offline_latch = false;
  604. }
  605. }
  606. }
  607. _ => {
  608. self.consecutive_healthy_checks = 0;
  609. }
  610. }
  611. if self.db_offline_latch {
  612. self.server_status = ServerStatus::Disconnected;
  613. } else {
  614. self.server_status = result.status;
  615. }
  616. }
  617. fn run_health_check(api_client: ApiClient, mut reauth_needed: bool) -> HealthCheckResult {
  618. let connected = match api_client.check_session_valid() {
  619. Ok(true) => {
  620. reauth_needed = false;
  621. true
  622. }
  623. Ok(false) => {
  624. reauth_needed = true;
  625. true
  626. }
  627. Err(e) => {
  628. log::warn!("Session status check error: {}", e);
  629. false
  630. }
  631. };
  632. if connected {
  633. let mut db_disconnected = false;
  634. if let Ok(true) = api_client.health_check() {
  635. if let Ok(info_opt) = api_client.health_info() {
  636. if let Some(info) = info_opt {
  637. let db_down = info.get("database").and_then(|v| v.as_str())
  638. .map(|s| s.eq_ignore_ascii_case("disconnected"))
  639. .unwrap_or(false)
  640. || info.get("database_connected").and_then(|v| v.as_bool())
  641. == Some(false)
  642. || info.get("db_connected").and_then(|v| v.as_bool())
  643. == Some(false)
  644. || info
  645. .get("db")
  646. .and_then(|v| v.as_str())
  647. .map(|s| s.eq_ignore_ascii_case("down"))
  648. .unwrap_or(false)
  649. || info
  650. .get("database")
  651. .and_then(|v| v.as_str())
  652. .map(|s| s.eq_ignore_ascii_case("down"))
  653. .unwrap_or(false);
  654. if db_down {
  655. db_disconnected = true;
  656. }
  657. }
  658. }
  659. }
  660. if db_disconnected {
  661. log::warn!("Database disconnected; treating as offline");
  662. HealthCheckResult {
  663. status: ServerStatus::Disconnected,
  664. reauth_required: reauth_needed,
  665. }
  666. } else {
  667. HealthCheckResult {
  668. status: ServerStatus::Connected,
  669. reauth_required: reauth_needed,
  670. }
  671. }
  672. } else {
  673. HealthCheckResult {
  674. status: ServerStatus::Disconnected,
  675. reauth_required: reauth_needed,
  676. }
  677. }
  678. }
  679. fn handle_ribbon_action(&mut self, action: String) {
  680. log::info!("Ribbon action triggered: {}", action);
  681. // Handle different action types
  682. if action.starts_with("search:") {
  683. let search_query = action.strip_prefix("search:").unwrap_or("");
  684. log::info!("Search action: {}", search_query);
  685. // TODO: Implement search functionality
  686. } else {
  687. match action.as_str() {
  688. // Dashboard actions
  689. "refresh_dashboard" => {
  690. if let Some(api_client) = &self.api_client {
  691. self.dashboard.refresh_data(api_client);
  692. }
  693. }
  694. "customize_dashboard" => {
  695. log::info!("Customize dashboard - TODO");
  696. }
  697. // Inventory actions
  698. "add_item" => {
  699. log::info!("Add item - TODO");
  700. }
  701. "edit_item" => {
  702. log::info!("Edit item - TODO");
  703. }
  704. "delete_item" => {
  705. log::info!("Delete item - TODO");
  706. }
  707. "print_label" => {
  708. log::info!("Print label - TODO");
  709. }
  710. // Quick actions
  711. "inventarize_quick" => {
  712. log::info!("Quick inventarize - TODO");
  713. }
  714. "checkout_checkin" => {
  715. log::info!("Check-out/in - TODO");
  716. }
  717. "start_room_audit" => {
  718. log::info!("Start room audit - TODO");
  719. }
  720. "start_spot_check" => {
  721. log::info!("Start spot-check - TODO");
  722. }
  723. _ => {
  724. log::info!("Unhandled action: {}", action);
  725. }
  726. }
  727. }
  728. }
  729. fn show_status_bar(&self, ctx: &egui::Context) {
  730. egui::TopBottomPanel::bottom("status_bar")
  731. .exact_height(24.0)
  732. .show_separator_line(false)
  733. .frame(
  734. egui::Frame::new()
  735. .fill(ctx.style().visuals.window_fill)
  736. .stroke(egui::Stroke::NONE),
  737. )
  738. .show(ctx, |ui| {
  739. ui.horizontal(|ui| {
  740. // Seqkel inikator
  741. let (icon, text, color) = match self.server_status {
  742. ServerStatus::Connected => (
  743. "-",
  744. if self.reauth_needed {
  745. "Server Connected • Re-auth required"
  746. } else {
  747. "Server Connected"
  748. },
  749. egui::Color32::from_rgb(76, 175, 80),
  750. ),
  751. ServerStatus::Disconnected => {
  752. // Check if we detected database timeout recently
  753. let timeout_detected = self.dashboard.has_timeout_error();
  754. let text = if timeout_detected {
  755. "Database Timeout - Retrying..."
  756. } else {
  757. "Server Disconnected"
  758. };
  759. ("x", text, egui::Color32::from_rgb(244, 67, 54))
  760. },
  761. ServerStatus::Checking => {
  762. ("~", "Checking...", egui::Color32::from_rgb(255, 152, 0))
  763. }
  764. ServerStatus::Unknown => (
  765. "??????????? -",
  766. "I don't know maybe connected maybe not ???",
  767. egui::Color32::GRAY,
  768. ),
  769. };
  770. ui.label(egui::RichText::new(icon).color(color).size(16.0));
  771. ui.label(egui::RichText::new(text).color(color).size(12.0));
  772. ui.separator();
  773. // Server URL
  774. if let Some(client) = &self.api_client {
  775. ui.label(
  776. egui::RichText::new(format!("Server: {}", client.base_url()))
  777. .size(11.0)
  778. .color(egui::Color32::GRAY),
  779. );
  780. }
  781. // User info on the right
  782. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
  783. if let Some(user) = &self.current_user {
  784. ui.label(
  785. egui::RichText::new(format!("User: {}", user.username))
  786. .size(11.0)
  787. .color(egui::Color32::GRAY),
  788. );
  789. }
  790. });
  791. });
  792. });
  793. }
  794. fn show_reconnect_overlay(&self, ctx: &egui::Context) {
  795. let screen_rect = ctx.viewport_rect();
  796. let visuals = ctx.style().visuals.clone();
  797. let dim_color = if visuals.dark_mode {
  798. egui::Color32::from_black_alpha(180)
  799. } else {
  800. egui::Color32::from_white_alpha(200)
  801. };
  802. // Dim the entire interface
  803. let layer_id = egui::LayerId::new(
  804. egui::Order::Foreground,
  805. egui::Id::new("reconnect_overlay_bg"),
  806. );
  807. ctx.layer_painter(layer_id)
  808. .rect_filled(screen_rect, 0.0, dim_color);
  809. // Capture input so underlying widgets don't receive clicks or keypresses
  810. egui::Area::new(egui::Id::new("reconnect_overlay_blocker"))
  811. .order(egui::Order::Foreground)
  812. .movable(false)
  813. .interactable(true)
  814. .fixed_pos(screen_rect.left_top())
  815. .show(ctx, |ui| {
  816. ui.set_min_size(screen_rect.size());
  817. ui.allocate_rect(ui.max_rect(), egui::Sense::click_and_drag());
  818. });
  819. let timeout_detected = self.dashboard.has_timeout_error();
  820. let message = if timeout_detected {
  821. "Database temporarily unavailable. Waiting for heartbeat…"
  822. } else {
  823. "Connection to the backend was lost. Retrying…"
  824. };
  825. // Foreground card with spinner and message
  826. egui::Area::new(egui::Id::new("reconnect_overlay_card"))
  827. .order(egui::Order::Foreground)
  828. .movable(false)
  829. .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
  830. .show(ctx, |ui| {
  831. ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
  832. ui.set_min_size(egui::vec2(360.0, 200.0));
  833. egui::Frame::default()
  834. .fill(visuals.panel_fill)
  835. .stroke(egui::Stroke::new(1.0, visuals.weak_text_color()))
  836. .corner_radius(12.0)
  837. .inner_margin(egui::Margin::symmetric(32, 24))
  838. .show(ui, |ui| {
  839. ui.vertical_centered(|ui| {
  840. ui.heading(
  841. egui::RichText::new("Reconnecting…")
  842. .color(visuals.strong_text_color())
  843. .size(20.0),
  844. );
  845. ui.add_space(8.0);
  846. ui.spinner();
  847. ui.label(
  848. egui::RichText::new(message)
  849. .color(visuals.text_color())
  850. .size(15.0),
  851. );
  852. ui.label(
  853. egui::RichText::new(
  854. "All actions are paused until the backend recovers.",
  855. )
  856. .color(visuals.weak_text_color())
  857. .size(13.0),
  858. );
  859. });
  860. });
  861. });
  862. // Keep spinner animating while offline
  863. ctx.request_repaint_after(std::time::Duration::from_millis(250));
  864. }
  865. fn should_block_interaction(&self) -> bool {
  866. self.api_client.is_some()
  867. && self.current_view != AppView::Login
  868. && (matches!(self.server_status, ServerStatus::Disconnected)
  869. || self.db_offline_latch)
  870. }
  871. /// Save current filter state before switching views
  872. fn save_filter_state_for_view(&mut self, view: AppView) {
  873. if let Some(ribbon) = &self.ribbon_ui {
  874. // Only save filter state for views that use filters
  875. if matches!(
  876. view,
  877. AppView::Inventory | AppView::Zones | AppView::Borrowing
  878. ) {
  879. self.view_filter_states
  880. .insert(view, ribbon.filter_builder.filter_group.clone());
  881. }
  882. }
  883. }
  884. /// Restore filter state when switching to a view
  885. fn restore_filter_state_for_view(&mut self, view: AppView) {
  886. if let Some(ribbon) = &mut self.ribbon_ui {
  887. // Check if we have saved state for this view
  888. if let Some(saved_state) = self.view_filter_states.get(&view) {
  889. ribbon.filter_builder.filter_group = saved_state.clone();
  890. } else {
  891. // No saved state - clear filters for this view (fresh start)
  892. ribbon.filter_builder.filter_group =
  893. crate::core::components::filter_builder::FilterGroup::new();
  894. }
  895. }
  896. }
  897. }
  898. impl eframe::App for BeepZoneApp {
  899. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
  900. // Inject queued OSK events at the start of the frame
  901. if !self.osk_event_queue.is_empty() {
  902. let events = std::mem::take(&mut self.osk_event_queue);
  903. ctx.input_mut(|i| i.events.extend(events));
  904. }
  905. // Track focus for OSK
  906. if let Some(id) = ctx.memory(|m| m.focused()) {
  907. self.last_focused_id = Some(id);
  908. }
  909. // Show OSK first so it reserves space at the bottom (preventing click-through and overlap)
  910. self.show_osk_overlay(ctx);
  911. // Detect view changes and save/restore filter state
  912. if let Some(prev_view) = self.previous_view {
  913. if prev_view != self.current_view {
  914. // Save filter state for the view we're leaving
  915. self.save_filter_state_for_view(prev_view);
  916. // Restore filter state for the view we're entering
  917. self.restore_filter_state_for_view(self.current_view);
  918. // Update available columns for the new view
  919. if let Some(ribbon) = &mut self.ribbon_ui {
  920. match self.current_view {
  921. AppView::Inventory => {
  922. // Ensure Inventory uses asset columns in the FilterBuilder
  923. ribbon.filter_builder.set_columns_for_context("assets");
  924. }
  925. AppView::Zones => {
  926. ribbon.filter_builder.available_columns = vec![
  927. ("Any".to_string(), "Any".to_string()),
  928. ("Zone Code".to_string(), "zones.zone_code".to_string()),
  929. ("Zone Name".to_string(), "zones.zone_name".to_string()),
  930. ];
  931. }
  932. AppView::Borrowing => {
  933. ribbon.filter_builder.available_columns =
  934. crate::ui::borrowing::BorrowingView::get_filter_columns();
  935. }
  936. _ => {}
  937. }
  938. }
  939. }
  940. }
  941. // Update previous view for next frame
  942. self.previous_view = Some(self.current_view);
  943. // Customize background color for light mode
  944. if !ctx.style().visuals.dark_mode {
  945. let mut style = (*ctx.style()).clone();
  946. style.visuals.panel_fill = egui::Color32::from_rgb(210, 210, 210);
  947. style.visuals.window_fill = egui::Color32::from_rgb(210, 210, 210);
  948. ctx.set_style(style);
  949. }
  950. // Check for login success
  951. if let Some((server_url, response)) = self.login_success.take() {
  952. self.handle_login_success(server_url, response);
  953. }
  954. // Process any completed health checks and schedule new ones
  955. self.poll_health_check();
  956. let predicted_block = self.should_block_interaction();
  957. let health_interval = self.desired_health_interval(predicted_block);
  958. if self.api_client.is_some()
  959. && !self.health_check_in_progress
  960. && self.last_health_check.elapsed().as_secs_f32() > health_interval
  961. {
  962. self.request_health_check();
  963. }
  964. // Show appropriate view
  965. if self.current_view == AppView::Login {
  966. self.login_screen.show(ctx, &mut self.login_success);
  967. } else {
  968. let mut block_interaction = self.should_block_interaction();
  969. if let Some(client) = &self.api_client {
  970. if client.take_timeout_signal() {
  971. log::warn!("Backend timeout detected via API client; entering reconnect mode");
  972. self.server_status = ServerStatus::Disconnected;
  973. self.db_offline_latch = true;
  974. self.last_timeout_at = Some(std::time::Instant::now());
  975. self.consecutive_healthy_checks = 0;
  976. block_interaction = true;
  977. // Force an immediate health re-check
  978. self.last_health_check = std::time::Instant::now()
  979. - std::time::Duration::from_secs(10);
  980. if !self.health_check_in_progress {
  981. self.request_health_check();
  982. }
  983. }
  984. }
  985. // When we're blocked, ensure a health check is queued so we recover ASAP
  986. if block_interaction
  987. && !self.health_check_in_progress
  988. && self.last_health_check.elapsed().as_secs_f32() > 1.0
  989. {
  990. self.request_health_check();
  991. }
  992. self.show_top_bar(ctx, block_interaction);
  993. let ribbon_action = if block_interaction || self.current_view == AppView::Login {
  994. None
  995. } else {
  996. self.show_ribbon(ctx)
  997. };
  998. self.show_status_bar(ctx);
  999. if !block_interaction {
  1000. if let Some(action) = ribbon_action {
  1001. self.handle_ribbon_action(action);
  1002. }
  1003. egui::CentralPanel::default().show(ctx, |ui| match self.current_view {
  1004. AppView::Dashboard => {
  1005. self.dashboard.show(ui, self.api_client.as_ref());
  1006. // Check if dashboard has timeout error and trigger health check
  1007. if self.dashboard.has_timeout_error() {
  1008. self.force_health_check();
  1009. }
  1010. }
  1011. AppView::Inventory => {
  1012. // Handle FilterBuilder popup BEFORE showing inventory
  1013. // This ensures filter changes are processed in the current frame
  1014. if let Some(ribbon) = &mut self.ribbon_ui {
  1015. let filter_changed = ribbon.filter_builder.show_popup(ctx);
  1016. if filter_changed {
  1017. ribbon
  1018. .checkboxes
  1019. .insert("inventory_filter_changed".to_string(), true);
  1020. }
  1021. }
  1022. self.inventory.show(
  1023. ui,
  1024. self.api_client.as_ref(),
  1025. self.ribbon_ui.as_mut(),
  1026. &self.session_manager,
  1027. self.current_permissions.as_ref(),
  1028. );
  1029. }
  1030. AppView::Categories => {
  1031. self.categories
  1032. .show(ui, self.api_client.as_ref(), self.ribbon_ui.as_mut(), self.current_permissions.as_ref());
  1033. }
  1034. AppView::Zones => {
  1035. if let Some(ribbon) = self.ribbon_ui.as_mut() {
  1036. // Handle FilterBuilder popup BEFORE showing zones view so changes are applied in the same frame
  1037. let filter_changed = ribbon.filter_builder.show_popup(ctx);
  1038. if filter_changed {
  1039. ribbon
  1040. .checkboxes
  1041. .insert("zones_filter_changed".to_string(), true);
  1042. }
  1043. self.zones.show(ui, self.api_client.as_ref(), ribbon, self.current_permissions.as_ref());
  1044. // Handle zone navigation request to inventory
  1045. if let Some((zone_code, zone_id)) = self.zones.switch_to_inventory_with_zone.take() {
  1046. log::info!("Switching to inventory with zone filter: {} (ID: {})", zone_code, zone_id);
  1047. // Save current Zones filter state
  1048. let zones_filter_state = ribbon.filter_builder.filter_group.clone();
  1049. self.view_filter_states
  1050. .insert(AppView::Zones, zones_filter_state);
  1051. // Set zone filter using the ID which is safer and faster
  1052. ribbon.filter_builder.set_single_filter(
  1053. "assets.zone_id".to_string(),
  1054. crate::core::components::filter_builder::FilterOperator::Is,
  1055. zone_id.to_string(),
  1056. );
  1057. // Switch to inventory view
  1058. self.current_view = AppView::Inventory;
  1059. ribbon.active_tab = "Inventory".to_string();
  1060. ribbon
  1061. .checkboxes
  1062. .insert("inventory_filter_changed".to_string(), true);
  1063. // Update previous_view to match so next frame doesn't restore old inventory filters
  1064. self.previous_view = Some(AppView::Inventory);
  1065. // Request repaint to ensure the filter is applied on the next frame
  1066. ctx.request_repaint();
  1067. }
  1068. } else {
  1069. // Fallback if no ribbon (shouldn't happen)
  1070. log::warn!("No ribbon available for zones view");
  1071. }
  1072. }
  1073. AppView::Borrowing => {
  1074. if let Some(ribbon) = self.ribbon_ui.as_mut() {
  1075. // Handle FilterBuilder popup
  1076. let filter_changed = ribbon.filter_builder.show_popup(ctx);
  1077. if filter_changed {
  1078. ribbon
  1079. .checkboxes
  1080. .insert("borrowing_filter_changed".to_string(), true);
  1081. }
  1082. self.borrowing
  1083. .show(ctx, ui, self.api_client.as_ref(), ribbon, self.current_permissions.as_ref());
  1084. // Handle borrower navigation request to inventory
  1085. if let Some(borrower_id) =
  1086. self.borrowing.switch_to_inventory_with_borrower.take()
  1087. {
  1088. log::info!(
  1089. "Switching to inventory with borrower filter: {}",
  1090. borrower_id
  1091. );
  1092. // Save current Borrowing filter state
  1093. let borrowing_filter_state = ribbon.filter_builder.filter_group.clone();
  1094. self.view_filter_states
  1095. .insert(AppView::Borrowing, borrowing_filter_state);
  1096. // Set borrower filter using the current_borrower_id from assets table
  1097. ribbon.filter_builder.set_single_filter(
  1098. "assets.current_borrower_id".to_string(),
  1099. crate::core::components::filter_builder::FilterOperator::Is,
  1100. borrower_id.to_string(),
  1101. );
  1102. // Switch to inventory view
  1103. self.current_view = AppView::Inventory;
  1104. ribbon.active_tab = "Inventory".to_string();
  1105. ribbon
  1106. .checkboxes
  1107. .insert("inventory_filter_changed".to_string(), true);
  1108. // Update previous_view to match so next frame doesn't restore old inventory filters
  1109. self.previous_view = Some(AppView::Inventory);
  1110. // Request repaint to ensure the filter is applied on the next frame
  1111. ctx.request_repaint();
  1112. }
  1113. } else {
  1114. // Fallback if no ribbon (shouldn't happen)
  1115. log::warn!("No ribbon available for borrowing view");
  1116. }
  1117. }
  1118. AppView::Audits => {
  1119. let user_id = self.current_user.as_ref().map(|u| u.id);
  1120. self.audits.show(ctx, ui, self.api_client.as_ref(), user_id, self.current_permissions.as_ref());
  1121. }
  1122. AppView::Templates => {
  1123. if let Some(ribbon) = self.ribbon_ui.as_mut() {
  1124. // Handle FilterBuilder popup BEFORE showing templates view
  1125. let filter_changed = ribbon.filter_builder.show_popup(ctx);
  1126. if filter_changed {
  1127. ribbon
  1128. .checkboxes
  1129. .insert("templates_filter_changed".to_string(), true);
  1130. }
  1131. let flags = self
  1132. .templates
  1133. .show(ui, self.api_client.as_ref(), Some(ribbon), self.current_permissions.as_ref());
  1134. for flag in flags {
  1135. ribbon.checkboxes.insert(flag, false);
  1136. }
  1137. } else {
  1138. self.templates.show(ui, self.api_client.as_ref(), None, self.current_permissions.as_ref());
  1139. }
  1140. }
  1141. AppView::Suppliers => {
  1142. if let Some(ribbon_ui) = self.ribbon_ui.as_mut() {
  1143. let flags = self.suppliers.show(
  1144. ui,
  1145. self.api_client.as_ref(),
  1146. Some(&mut *ribbon_ui),
  1147. self.current_permissions.as_ref(),
  1148. );
  1149. for flag in flags {
  1150. ribbon_ui.checkboxes.insert(flag, false);
  1151. }
  1152. } else {
  1153. let _ = self.suppliers.show(ui, self.api_client.as_ref(), None, self.current_permissions.as_ref());
  1154. }
  1155. }
  1156. AppView::IssueTracker => {
  1157. self.issues.show(ui, self.api_client.as_ref(), self.current_permissions.as_ref());
  1158. }
  1159. AppView::Printers => {
  1160. // Render printers dropdown in ribbon if we're on printers tab
  1161. if let Some(ribbon) = self.ribbon_ui.as_mut() {
  1162. if ribbon.active_tab == "Printers" {
  1163. self.printers
  1164. .inject_dropdown_into_ribbon(ribbon, &self.session_manager);
  1165. }
  1166. }
  1167. self.printers.render(
  1168. ui,
  1169. self.api_client.as_ref(),
  1170. self.ribbon_ui.as_mut(),
  1171. &self.session_manager,
  1172. self.current_permissions.as_ref(),
  1173. );
  1174. }
  1175. AppView::LabelTemplates => {
  1176. self.label_templates.render(
  1177. ui,
  1178. self.api_client.as_ref(),
  1179. self.ribbon_ui.as_mut(),
  1180. self.current_permissions.as_ref(),
  1181. );
  1182. }
  1183. AppView::Login => {
  1184. // Do nothing, we are transitioning to logout
  1185. }
  1186. });
  1187. } else {
  1188. self.show_reconnect_overlay(ctx);
  1189. }
  1190. }
  1191. // Re-authentication modal when needed (only outside of Login view)
  1192. if self.reauth_needed && self.current_view != AppView::Login {
  1193. let mut keep_open = true;
  1194. egui::Window::new("Session expired")
  1195. .collapsible(false)
  1196. .resizable(false)
  1197. .movable(true)
  1198. .open(&mut keep_open)
  1199. .show(ctx, |ui| {
  1200. ui.label("Your session has expired or is invalid. Reenter your password to continue.");
  1201. ui.add_space(8.0);
  1202. let mut pw = std::mem::take(&mut self.reauth_password);
  1203. let response = ui.add(
  1204. egui::TextEdit::singleline(&mut pw)
  1205. .password(true)
  1206. .hint_text("Password")
  1207. .desired_width(260.0),
  1208. );
  1209. self.reauth_password = pw;
  1210. ui.add_space(8.0);
  1211. ui.horizontal(|ui| {
  1212. let mut try_login = ui.button("Reauthenticate").clicked();
  1213. // Allow Enter to submit
  1214. try_login |= response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
  1215. if try_login {
  1216. if let (Some(client), Some(user)) = (self.api_client.as_mut(), self.current_user.as_ref()) {
  1217. // Attempt password login to refresh token
  1218. match client.login_password(&user.username, &self.reauth_password) {
  1219. Ok(resp) => {
  1220. let server_url = client.base_url().to_string();
  1221. self.handle_reauth_success(server_url, resp);
  1222. self.reauth_needed = false;
  1223. self.reauth_password.clear();
  1224. // Avoid immediate re-flagging by pushing out the next health check
  1225. self.last_health_check = std::time::Instant::now();
  1226. }
  1227. Err(e) => {
  1228. log::error!("Reauth failed: {}", e);
  1229. }
  1230. }
  1231. }
  1232. }
  1233. if ui.button("Go to Login").clicked() {
  1234. self.handle_logout();
  1235. self.reauth_needed = false;
  1236. self.reauth_password.clear();
  1237. }
  1238. });
  1239. });
  1240. if !keep_open {
  1241. // Close button pressed: just dismiss (will reappear on next check if still invalid)
  1242. self.reauth_needed = false;
  1243. self.reauth_password.clear();
  1244. self.last_health_check = std::time::Instant::now();
  1245. }
  1246. }
  1247. // About dialog
  1248. if self.show_about {
  1249. egui::Window::new("About BeepZone")
  1250. .collapsible(false)
  1251. .resizable(false)
  1252. .show(ctx, |ui| {
  1253. ui.heading("BeepZone Desktop Client");
  1254. ui.heading("- eGUI EMO Edition");
  1255. ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION")));
  1256. ui.separator();
  1257. ui.label("A crude inventory system meant to run on any potato!");
  1258. ui.label("- Fueled by peanut butter and caffeine");
  1259. ui.label("- Backed by Spaghetti codebase supreme pro plus ultra");
  1260. ui.label("- Running at all thanks to vibe coding and sheer willpower");
  1261. ui.label("- Oles Approved");
  1262. ui.label("- Atleast tries to be a good fucking inventory system!");
  1263. ui.separator();
  1264. ui.label("Made with love (and some hatred) by crt ");
  1265. ui.separator();
  1266. if ui.button("Close this goofy ah panel").clicked() {
  1267. self.show_about = false;
  1268. }
  1269. });
  1270. }
  1271. }
  1272. }
  1273. impl BeepZoneApp {
  1274. fn show_osk_overlay(&mut self, ctx: &egui::Context) {
  1275. if !self.show_osk { return; }
  1276. let height = 340.0;
  1277. egui::TopBottomPanel::bottom("osk_panel")
  1278. .resizable(false)
  1279. .min_height(height)
  1280. .show(ctx, |ui| {
  1281. ui.vertical_centered(|ui| {
  1282. ui.add_space(10.0);
  1283. // Styling
  1284. let btn_size = egui::vec2(50.0, 50.0);
  1285. let spacing = 6.0;
  1286. ui.style_mut().spacing.item_spacing = egui::vec2(spacing, spacing);
  1287. // Layouts
  1288. let rows_lower = [
  1289. vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "="],
  1290. vec!["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]"],
  1291. vec!["a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "\\"],
  1292. vec!["z", "x", "c", "v", "b", "n", "m", ",", ".", "/"],
  1293. ];
  1294. let rows_upper = [
  1295. vec!["!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+"],
  1296. vec!["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{", "}"],
  1297. vec!["A", "S", "D", "F", "G", "H", "J", "K", "L", ":", "\"", "|"],
  1298. vec!["Z", "X", "C", "V", "B", "N", "M", "<", ">", "?"],
  1299. ];
  1300. let rows = if self.osk_shift_mode { rows_upper } else { rows_lower };
  1301. for row in rows {
  1302. ui.horizontal(|ui| {
  1303. // Center row
  1304. let width = row.len() as f32 * (btn_size.x + spacing) - spacing;
  1305. let margin = (ui.available_width() - width) / 2.0;
  1306. if margin > 0.0 { ui.add_space(margin); }
  1307. for key in row {
  1308. if ui.add_sized(btn_size, egui::Button::new(egui::RichText::new(key).size(24.0))).clicked() {
  1309. // Queue text event for next frame
  1310. self.osk_event_queue.push(egui::Event::Text(key.to_string()));
  1311. // Restore focus immediately
  1312. if let Some(id) = self.last_focused_id {
  1313. ctx.memory_mut(|m| m.request_focus(id));
  1314. }
  1315. }
  1316. }
  1317. });
  1318. }
  1319. // Modifiers and Actions
  1320. ui.horizontal(|ui| {
  1321. let shift_width = 100.0;
  1322. let space_width = 300.0;
  1323. let back_width = 100.0;
  1324. let total_width = shift_width + space_width + back_width + (spacing * 2.0);
  1325. let margin = (ui.available_width() - total_width) / 2.0;
  1326. if margin > 0.0 { ui.add_space(margin); }
  1327. // Shift
  1328. let shift_text = if self.osk_shift_mode { "SHIFT (ON)" } else { "SHIFT" };
  1329. let shift_btn = egui::Button::new(egui::RichText::new(shift_text).size(20.0))
  1330. .fill(if self.osk_shift_mode { egui::Color32::from_rgb(100, 100, 255) } else { ui.visuals().widgets.inactive.bg_fill });
  1331. if ui.add_sized(egui::vec2(shift_width, 50.0), shift_btn).clicked() {
  1332. self.osk_shift_mode = !self.osk_shift_mode;
  1333. if let Some(id) = self.last_focused_id {
  1334. ctx.memory_mut(|m| m.request_focus(id));
  1335. }
  1336. }
  1337. // Space
  1338. if ui.add_sized(egui::vec2(space_width, 50.0), egui::Button::new(egui::RichText::new("SPACE").size(20.0))).clicked() {
  1339. self.osk_event_queue.push(egui::Event::Text(" ".to_string()));
  1340. if let Some(id) = self.last_focused_id {
  1341. ctx.memory_mut(|m| m.request_focus(id));
  1342. }
  1343. }
  1344. // Backspace
  1345. if ui.add_sized(egui::vec2(back_width, 50.0), egui::Button::new(egui::RichText::new("BACK").size(20.0))).clicked() {
  1346. self.osk_event_queue.push(egui::Event::Key {
  1347. key: egui::Key::Backspace,
  1348. pressed: true,
  1349. modifiers: egui::Modifiers::NONE,
  1350. repeat: false,
  1351. physical_key: None,
  1352. });
  1353. if let Some(id) = self.last_focused_id {
  1354. ctx.memory_mut(|m| m.request_focus(id));
  1355. }
  1356. }
  1357. });
  1358. ui.add_space(10.0);
  1359. });
  1360. });
  1361. }
  1362. }