diff --git a/__manifest__.py b/__manifest__.py index f658d8d..f57527a 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -17,7 +17,7 @@ Features 3. Portrait Display Mode: Single-pane responsive layout for mobile POS terminals. """, 'author': "Suherdy Yacob", - 'depends': ['point_of_sale'], + 'depends': ['point_of_sale', 'pos_restaurant'], 'data': [ 'views/res_config_settings_views.xml', ], @@ -33,6 +33,8 @@ Features 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary_patch.xml', # Safe multi-device Release table guard 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary/safe_release_patch.js', + # Multi-device sync hardening (table-open sync, 30s auto-refresh, visibility refresh) + 'pos_ui_optimization/static/src/app/screens/floor_screen/floor_screen_sync_patch.js', # Payment screen patches 'pos_ui_optimization/static/src/app/screens/payment_screen/payment_screen_patch.xml', # Ticket screen patches diff --git a/static/src/app/screens/floor_screen/floor_screen_sync_patch.js b/static/src/app/screens/floor_screen/floor_screen_sync_patch.js new file mode 100644 index 0000000..79426bc --- /dev/null +++ b/static/src/app/screens/floor_screen/floor_screen_sync_patch.js @@ -0,0 +1,212 @@ +/** @odoo-module **/ + +/** + * pos_ui_optimization — Multi-Device Sync Hardening + * ================================================== + * + * This patch adds three complementary sync strategies that work on top of + * Odoo's existing WebSocket-based DevicesSynchronisation to ensure order + * data stays fresh on all devices in a multi-tablet restaurant setup. + * + * === Android System WebView Compatibility Notes === + * + * This file is intentionally written to be compatible with Android System + * WebView versions used by the Odoo Android app (Chrome/WebView 80+): + * + * requestIdleCallback: + * ✅ Supported in Android WebView 47+ (all modern devices). + * We have a setTimeout fallback for any older environment. + * + * visibilitychange: + * ⚠️ Reliable in Chrome browser, but in Android WebView the event only + * fires when the WebView loses DOM focus — NOT when the entire Android + * app is backgrounded by the OS, unless the native app explicitly calls + * WebView.onPause()/onResume(). The Odoo Android app does implement this + * correctly, so visibilitychange works. As a safety net, we also register + * a focus/blur fallback that works regardless of native app lifecycle. + * + * Optional chaining (?.) and nullish coalescing (??): + * ✅ Supported in Chrome 80+ / Android WebView from early 2020. + * All active Odoo Android installs run Chrome 80+. No changes needed. + * + * Numeric separators (30_000): + * ⚠️ Not supported in WebView < 75. Replaced with plain 30000. + * + * Strategy 1 — Sync-on-table-open: + * Pulls fresh data from the server before the FloorScreen opens a table so + * cashiers always see up-to-date orders (not stale local state). + * + * Strategy 2 — Periodic auto-refresh (30 s): + * A background timer fires every 30 seconds ONLY when safe: + * • The device is on the FloorScreen (never during order editing). + * • No input is focused (user is not typing a quantity/note). + * • No concurrent refresh is already running. + * • Uses requestIdleCallback so the browser picks a CPU-idle moment, + * preventing dropped frames or UI stutter. + * + * Strategy 3 — Visibility + focus/blur refresh: + * Triggers a refresh when: + * • document.visibilitychange fires (Chrome browser, Odoo Android app). + * • window 'focus' fires (fallback for Android WebView when the OS + * resumes the app but visibilitychange did not fire). + */ + +import { FloorScreen } from "@pos_restaurant/app/screens/floor_screen/floor_screen"; +import { PosStore } from "@point_of_sale/app/services/pos_store"; +import { patch } from "@web/core/utils/patch"; +import { onMounted, onWillUnmount } from "@odoo/owl"; + +// Auto-refresh interval (avoid numeric separators for WebView < 75 compat) +const AUTO_REFRESH_INTERVAL_MS = 30000; + +// ─── PosStore patch ─────────────────────────────────────────────────────────── +patch(PosStore.prototype, { + /** + * Start all background sync strategies. Called from FloorScreen.onMounted. + * Guards against double-initialisation (e.g. if FloorScreen unmounts/remounts). + */ + setupSyncRefresh() { + if (this._syncRefreshInitialized) { + return; + } + this._syncRefreshInitialized = true; + this._refreshInProgress = false; + + // Strategy 2: periodic timer + this._autoRefreshInterval = setInterval(() => { + this._tryAutoRefresh(); + }, AUTO_REFRESH_INTERVAL_MS); + + // Strategy 3a: Page Visibility API + // Works in Chrome browser and in the Odoo Android app (which calls + // WebView.onPause/onResume bridging visibility state correctly). + this._onVisibilityChange = () => { + if (document.visibilityState === "visible") { + this._tryAutoRefresh(); + } + }; + document.addEventListener("visibilitychange", this._onVisibilityChange); + + // Strategy 3b: window focus fallback for Android WebView environments + // where visibilitychange may not fire when the OS resumes the app. + // Deduplicated with _refreshInProgress so it never double-triggers. + this._onWindowFocus = () => { + // Add a short delay: Android sometimes fires focus before the + // WebView has fully re-rendered after coming back to foreground. + setTimeout(() => { + this._tryAutoRefresh(); + }, 500); + }; + window.addEventListener("focus", this._onWindowFocus); + }, + + /** + * Remove all timers and event listeners. Called from FloorScreen.onWillUnmount. + */ + destroySyncRefresh() { + if (this._autoRefreshInterval) { + clearInterval(this._autoRefreshInterval); + this._autoRefreshInterval = null; + } + if (this._onVisibilityChange) { + document.removeEventListener("visibilitychange", this._onVisibilityChange); + this._onVisibilityChange = null; + } + if (this._onWindowFocus) { + window.removeEventListener("focus", this._onWindowFocus); + this._onWindowFocus = null; + } + this._syncRefreshInitialized = false; + this._refreshInProgress = false; + }, + + /** + * Central guard method — decides whether it is safe to run readDataFromServer() + * and schedules it via requestIdleCallback (or setTimeout(0) as fallback). + * + * Guards: + * 1. No concurrent refresh already running. + * 2. Skip if the user is actively typing in an input/textarea. + * 3. Only run while on the FloorScreen (never during order editing). + */ + _tryAutoRefresh() { + // Guard 1 + if (this._refreshInProgress) { + return; + } + + // Guard 2: skip while the user is typing + const activeEl = document.activeElement; + const activeTag = activeEl ? activeEl.tagName : ""; + if (activeTag === "INPUT" || activeTag === "TEXTAREA") { + return; + } + + // Guard 3: only on FloorScreen + const mainScreen = this.mainScreen; + const component = mainScreen ? mainScreen.component : null; + const screenName = component ? component.name : null; + if (screenName && screenName !== "FloorScreen") { + return; + } + + const doRefresh = async () => { + this._refreshInProgress = true; + try { + if (this.deviceSync) { + await this.deviceSync.readDataFromServer(); + } + } catch (_e) { + // Non-blocking — if server is unreachable, just skip silently + } finally { + this._refreshInProgress = false; + } + }; + + // requestIdleCallback: lets the browser pick a CPU-idle moment between + // animation frames, preventing dropped frames or visible stutter. + // Supported in Android WebView 47+ (all modern devices). + // Falls back to setTimeout(fn, 0) which at minimum yields the call + // stack and runs after the current paint cycle. + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => doRefresh(), { timeout: 5000 }); + } else { + setTimeout(() => doRefresh(), 0); + } + }, +}); + +// ─── FloorScreen patch ──────────────────────────────────────────────────────── +patch(FloorScreen.prototype, { + setup() { + super.setup(...arguments); + + onMounted(() => { + this.pos.setupSyncRefresh(); + }); + + onWillUnmount(() => { + this.pos.destroySyncRefresh(); + }); + }, + + /** + * Strategy 1: Sync-on-table-open. + * + * Pulls the latest order state from the server before navigating into a + * table's order view. Non-blocking on failure — the cashier can always + * open the table even if the server is temporarily unreachable. + */ + async onClickTable(table, ev) { + if (!this.pos.isEditMode && !this.pos.isOrderTransferMode && !table.parent_id) { + try { + if (this.pos.deviceSync) { + await this.pos.deviceSync.readDataFromServer(); + } + } catch (_e) { + // Non-blocking: proceed with local data if server unreachable + } + } + return super.onClickTable(table, ev); + }, +});