app.rs 63 KB

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