From 945db1d0c2c8813c78f2627c2d99c655d6d5ad41 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 26 May 2026 08:43:33 +0700 Subject: [PATCH] feat: implement mobile-responsive portrait mode layout for PoS screens with tab-based navigation --- .gitignore | 42 ++- README.md | 136 ++++++- __manifest__.py | 11 + .../src/app/components/navbar/navbar_patch.js | 15 + .../app/components/navbar/navbar_patch.xml | 18 + .../product_screen/portrait_mode_patch.js | 22 ++ .../product_screen/portrait_screen.xml | 107 ++++++ static/src/app/services/portrait_mode.js | 63 ++++ static/src/scss/portrait.scss | 331 ++++++++++++++++++ 9 files changed, 726 insertions(+), 19 deletions(-) create mode 100644 static/src/app/components/navbar/navbar_patch.js create mode 100644 static/src/app/components/navbar/navbar_patch.xml create mode 100644 static/src/app/screens/product_screen/portrait_mode_patch.js create mode 100644 static/src/app/screens/product_screen/portrait_screen.xml create mode 100644 static/src/app/services/portrait_mode.js create mode 100644 static/src/scss/portrait.scss diff --git a/.gitignore b/.gitignore index ddcb816..5947bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,42 @@ -*.pyc -*~ +# Python __pycache__/ -.DS_Store +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +*.egg +*.pyc + +# Odoo +.odoo_module_info + +# Editor / IDE .vscode/ .idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V500 +.Trashes +ehthumbs.db +Thumbs.db + +# Node / npm (if any frontend tooling) +node_modules/ +npm-debug.log* +yarn-error.log + +# Logs +*.log + +# Git merge artifacts +*.orig +*.rej + +# Compiled / generated assets (Odoo builds these at runtime) +/static/description/index.html diff --git a/README.md b/README.md index 4155701..8f9e979 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,131 @@ # POS UI Optimization -This Odoo 19 module optimizes the Point of Sale (POS) user interface for low-RAM devices (e.g., Android tablets with 2GB RAM). +**Odoo 19 | Custom Addon** + +Optimizes the Point of Sale UI for resource-constrained devices and adds a **switchable portrait display mode** designed for 6-inch touchscreen terminals. + +--- ## Features -- **Product List Incremental Loading**: Renders products in batches of 40 as you scroll, significantly reducing memory usage in categories with many products. -- **Order Cart Incremental Loading**: Efficiently handles large orders by rendering order lines incrementally as you scroll through the cart. -- **Improved Responsiveness**: Keeps the browser DOM light and prevents "white blank" screens caused by memory exhaustion. -- **Legacy Browser Support**: Designed without modern ES2020 JavaScript syntax (like optional chaining `?.`) to ensure compatibility with older Android browser engines found on specific legacy POS hardware. +### 1. Incremental Product Rendering +Prevents low-RAM devices from freezing when the product catalogue is large. + +- Only **40 products** are rendered on initial load +- More products load automatically as the user scrolls down +- Resets the counter when the search word or category changes + +### 2. Incremental Order Line Rendering +Same approach for the order/cart display. + +- Only **20 order lines** rendered initially +- Scrolling loads more lines progressively +- Ensures the selected line is always visible (expands count if needed) + +### 3. Portrait Mode (6-inch Display) + +A dedicated compact layout for portrait-orientation small screens. Designed for devices like 6-inch Android POS terminals where the standard Odoo two-column layout clutters the screen. + +#### Layout + +``` +┌──────────────────────────────┐ +│ Navbar (compact) │ +├──────────────────────────────┤ +│ │ +│ Active tab pane: │ +│ Products / Cart / Numpad │ +│ │ +├──────────────────────────────┤ +│ TOTAL: Rp X,XXX [Send][Pay]│ ← always visible +├──────────────────────────────┤ +│ [Products] [Cart] [Numpad]│ ← bottom tab bar +└──────────────────────────────┘ +``` + +#### Key Differences from Standard Mode + +| Feature | Standard | Portrait | +|---|---|---| +| Layout | Two-column (order left, products right) | Single pane with bottom tabs | +| Product grid | `auto-fill minmax(115px)` | Fixed 2 columns | +| Product images | Full aspect ratio | Capped at 90px height | +| Numpad | Shown inline in left pane | Dedicated "Numpad" tab | +| Pay/Send | Inside left pane action pad | Always-visible bottom strip | +| Navbar | Full labels | Compact with smaller padding | + +#### Auto-Activation + +Portrait mode **auto-activates** when the browser viewport width is **< 400 px** on first load. A manual override is always available via the burger menu (☰ → Switch to Portrait/Standard Mode). The choice is saved to `localStorage`. + +#### Send Button Behavior + +The **Send** button appears only when: +1. The POS is in restaurant mode (`module_pos_restaurant = True`) +2. At least one **Preparation Category** is configured in POS → Kitchen Printers settings +3. The current order has unsent changes (`nbrOfChanges > 0`) +4. The order is not a direct sale / refund + +This mirrors Odoo's standard restaurant `swapButton` logic exactly. + +--- ## Installation -1. Place the `pos_ui_optimization` folder in your Odoo custom addons directory. -2. Restart your Odoo server. -3. In Odoo, activate **Developer Mode**. -4. Go to **Apps** -> **Update Apps List**. -5. Search for `POS UI Optimization`. -6. Click **Install**. +1. Copy the `pos_ui_optimization` folder into your `customaddons` directory +2. Restart the Odoo server +3. Go to **Apps** → search for **POS UI Optimization** → Install +4. Reload your POS session -## Technical Details +## Upgrade -This module uses Odoo's JavaScript patching mechanism (`patch` from `@web/core/utils/patch`) to extend the core POS components: -- `ProductScreen`: Adds `displayedProductsCount` state via `owl`'s `useEffect` and an overridden `onScroll` hook to the product container. -- `OrderDisplay`: Adds `displayedCount` state and a scroll listener to the combo sorted order lines container. +```bash +./odoo-bin -u pos_ui_optimization -d +``` -No core Odoo files are modified. +--- + +## Compatibility + +| Item | Value | +|---|---| +| Odoo version | 19.0 | +| Depends on | `point_of_sale` | +| Compatible with | `pos_restaurant`, `pos_custom_access`, `pos_kitchen_printer` | +| License | LGPL-3 | + +--- + +## File Structure + +``` +pos_ui_optimization/ +├── __manifest__.py +├── README.md +├── static/ +│ └── src/ +│ ├── app/ +│ │ ├── components/ +│ │ │ ├── navbar/ +│ │ │ │ ├── navbar_patch.js # Display Mode toggle in burger menu +│ │ │ │ └── navbar_patch.xml +│ │ │ └── order_display/ +│ │ │ ├── order_display_patch.js # Incremental order line rendering +│ │ │ └── order_display_patch.xml +│ │ ├── screens/ +│ │ │ └── product_screen/ +│ │ │ ├── portrait_mode_patch.js # Tab state (products/cart/numpad) +│ │ │ ├── portrait_screen.xml # Portrait layout XML patch +│ │ │ ├── product_screen_patch.js # Incremental product rendering +│ │ │ └── product_screen_patch.xml +│ │ └── services/ +│ │ └── portrait_mode.js # Auto-detect + localStorage service +│ └── scss/ +│ └── portrait.scss # All portrait CSS (scoped to .pos-portrait) +``` + +--- + +## Author + +**Suherdy Yacob** diff --git a/__manifest__.py b/__manifest__.py index 0d94094..8b9a0ba 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -8,10 +8,21 @@ 'data': [], 'assets': { 'point_of_sale._assets_pos': [ + # Incremental rendering patches (existing) 'pos_ui_optimization/static/src/app/components/order_display/order_display_patch.js', 'pos_ui_optimization/static/src/app/components/order_display/order_display_patch.xml', 'pos_ui_optimization/static/src/app/screens/product_screen/product_screen_patch.js', 'pos_ui_optimization/static/src/app/screens/product_screen/product_screen_patch.xml', + # Portrait mode — service must load first + 'pos_ui_optimization/static/src/app/services/portrait_mode.js', + # Navbar display-mode toggle + 'pos_ui_optimization/static/src/app/components/navbar/navbar_patch.js', + 'pos_ui_optimization/static/src/app/components/navbar/navbar_patch.xml', + # ProductScreen portrait tab state + layout + 'pos_ui_optimization/static/src/app/screens/product_screen/portrait_mode_patch.js', + 'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml', + # Portrait CSS (must load after all XML patches) + 'pos_ui_optimization/static/src/scss/portrait.scss', ], }, 'installable': True, diff --git a/static/src/app/components/navbar/navbar_patch.js b/static/src/app/components/navbar/navbar_patch.js new file mode 100644 index 0000000..cf211ac --- /dev/null +++ b/static/src/app/components/navbar/navbar_patch.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { Navbar } from "@point_of_sale/app/components/navbar/navbar"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; + +patch(Navbar.prototype, { + setup() { + super.setup(); + this.portraitMode = useService("portrait_mode"); + }, + toggleDisplayMode() { + this.portraitMode.toggle(); + }, +}); diff --git a/static/src/app/components/navbar/navbar_patch.xml b/static/src/app/components/navbar/navbar_patch.xml new file mode 100644 index 0000000..2aeb449 --- /dev/null +++ b/static/src/app/components/navbar/navbar_patch.xml @@ -0,0 +1,18 @@ + + + + + + + + + Switch to Standard Mode + + + + Switch to Portrait Mode + + + + + diff --git a/static/src/app/screens/product_screen/portrait_mode_patch.js b/static/src/app/screens/product_screen/portrait_mode_patch.js new file mode 100644 index 0000000..393aec3 --- /dev/null +++ b/static/src/app/screens/product_screen/portrait_mode_patch.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ + +import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen"; +import { patch } from "@web/core/utils/patch"; +import { useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +patch(ProductScreen.prototype, { + setup() { + super.setup(); + this.portraitMode = useService("portrait_mode"); + this.portraitState = useState({ tab: "products" }); + }, + + get portraitTab() { + return this.portraitState.tab; + }, + + switchPortraitTab(tab) { + this.portraitState.tab = tab; + }, +}); diff --git a/static/src/app/screens/product_screen/portrait_screen.xml b/static/src/app/screens/product_screen/portrait_screen.xml new file mode 100644 index 0000000..1d2e42d --- /dev/null +++ b/static/src/app/screens/product_screen/portrait_screen.xml @@ -0,0 +1,107 @@ + + + + + + + { + 'is-portrait': portraitMode.isPortrait, + 'portrait-tab-products': portraitMode.isPortrait and portraitTab === 'products', + 'portrait-tab-cart': portraitMode.isPortrait and portraitTab === 'cart', + 'portrait-tab-numpad': portraitMode.isPortrait and portraitTab === 'numpad', + } + + + + + + + + + diff --git a/static/src/app/services/portrait_mode.js b/static/src/app/services/portrait_mode.js new file mode 100644 index 0000000..1c7e636 --- /dev/null +++ b/static/src/app/services/portrait_mode.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ + +import { reactive } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +const AUTO_BREAKPOINT = 400; +const STORAGE_KEY = "posDisplayMode"; + +const portraitModeService = { + dependencies: [], + start() { + // Determine initial mode: manual preference takes priority, then auto-detect + const stored = localStorage.getItem(STORAGE_KEY); + let isPortrait; + if (stored === "portrait") { + isPortrait = true; + } else if (stored === "standard") { + isPortrait = false; + } else { + // No manual preference — auto-detect from screen width + isPortrait = window.innerWidth < AUTO_BREAKPOINT; + } + + const state = reactive({ isPortrait }); + + const applyClass = () => { + document.body.classList.toggle("pos-portrait", state.isPortrait); + }; + + applyClass(); + + // Auto-switch on resize (only when no manual preference is stored) + let resizeTimer; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (!localStorage.getItem(STORAGE_KEY)) { + const autoPortrait = window.innerWidth < AUTO_BREAKPOINT; + if (state.isPortrait !== autoPortrait) { + state.isPortrait = autoPortrait; + applyClass(); + } + } + }, 200); + }); + + return { + get isPortrait() { + return state.isPortrait; + }, + toggle() { + state.isPortrait = !state.isPortrait; + localStorage.setItem( + STORAGE_KEY, + state.isPortrait ? "portrait" : "standard" + ); + applyClass(); + }, + }; + }, +}; + +registry.category("services").add("portrait_mode", portraitModeService); diff --git a/static/src/scss/portrait.scss b/static/src/scss/portrait.scss new file mode 100644 index 0000000..4830eb7 --- /dev/null +++ b/static/src/scss/portrait.scss @@ -0,0 +1,331 @@ +/** + * Portrait Mode — POS UI for 6-inch portrait devices + * All rules scoped under .pos-portrait to avoid affecting standard mode. + */ + +/* ============================================================ + NAVBAR — compact, icon-friendly + ============================================================ */ +.pos-portrait { + .pos-topheader { + min-height: 44px !important; + padding: 4px 6px !important; + + .btn { + padding: 5px 10px !important; + font-size: 0.82rem !important; + } + + // Shrink search input width + .o_searchview { + max-width: 120px; + } + + // Order tabs: shorten + .order-tabs { + max-width: 120px; + overflow: hidden; + } + } + + /* ============================================================ + PRODUCT SCREEN ROOT — vertical single-pane + ============================================================ */ + .product-screen { + flex-direction: column !important; + position: relative !important; // anchor for absolute footer + + /* ---- LEFT PANE (Cart / Numpad) ---- */ + .leftpane { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + flex: 1 1 auto !important; + flex-shrink: 1 !important; + border-right: none !important; + border-bottom: 1px solid #dee2e6; + // Must match portrait-footer height so content is not covered + padding-bottom: 122px !important; + // Hidden by default; tab-switching reveals it + display: none !important; + + // In portrait mode the desktop ControlButtons row is hidden + .control-buttons { + display: none !important; + } + + // Compact numpad for fat fingers + .numpad { + .numpad-button { + min-height: 54px !important; + font-size: 1.15rem !important; + } + } + + // Give OrderSummary room to scroll + .order-container { + // Enough room: screen height minus navbar(44) minus pay-strip(52) minus tab-bar(60) minus numpad+actionpad + max-height: calc(100dvh - 44px - 122px - 220px); + overflow-y: auto; + } + + // Keep subpads tight + .subpads { + padding-top: 6px !important; + } + + // Hide the ActionpadWidget in leftpane — pay strip handles Pay/Send + // (keeps the restaurant patches active for nbrOfChanges tracking, + // but we show our own buttons in the pay strip) + .actionpad { + display: none !important; + } + } + + /* ---- RIGHT PANE (Products) ---- */ + .rightpane { + flex: 1 1 auto !important; + width: 100% !important; + max-width: 100% !important; + // Push content above the fixed footer (pay strip ~52px + tab bar ~60px + gap) + padding-bottom: 122px !important; + overflow: hidden; + } + + /* ---- 2-COLUMN PRODUCT GRID ---- */ + .product-list { + grid-template-columns: repeat(2, 1fr) !important; + gap: 6px !important; + padding: 6px !important; + } + + /* ---- COMPACT PRODUCT CARDS ---- */ + // Cap the image so cards don't dominate the screen + .product-img { + max-height: 90px !important; + // ratio-4x3 makes it ~75% of width — on 2-col that's ~135px. + // Override the ratio so the image stays <= 90px tall. + --bs-aspect-ratio: 60% !important; + + img { + max-height: 90px !important; + object-fit: cover; + } + } + + // Minimum card height for no-image products + .rightpane .product:where(.product:not(:has(.product-img))) { + min-height: 4.5rem !important; + } + + // Product name: 2 lines max, smaller font + .product-name { + font-size: 0.78rem !important; + -webkit-line-clamp: 2 !important; + + &.no-image { + aspect-ratio: 3 / 2 !important; + } + } + + /* ---- HIDE ORIGINAL MOBILE SWITCHPANE ---- */ + // The standard "Pay | Cart" bottom bar must not show in portrait mode + .switchpane { + display: none !important; + } + + /* ============================================================ + TAB VISIBILITY — driven by class on .product-screen + ============================================================ */ + &.portrait-tab-products { + .leftpane { display: none !important; } + .rightpane { display: flex !important; } + } + + &.portrait-tab-cart { + .leftpane { + display: flex !important; + // Show full left pane including numpad + .subpads { display: flex; } + } + .rightpane { display: none !important; } + } + + &.portrait-tab-numpad { + .leftpane { + display: flex !important; + // Compact order list, expand numpad area + .order-container { max-height: 35vh !important; } + } + .rightpane { display: none !important; } + } + + /* ============================================================ + PORTRAIT FOOTER — fixed at the bottom of .product-screen + ============================================================ */ + .portrait-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + display: flex; + flex-direction: column; + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08); + } + + /* ---- PAY STRIP ---- */ + .portrait-pay-strip { + background: #fff; + border-top: 1px solid #dee2e6; + min-height: 52px; + + .portrait-total-amount { + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; + } + + .portrait-pay-btn, + .portrait-send-btn { + border-radius: 10px; + font-weight: 600; + font-size: 0.9rem; + padding: 8px 14px; + } + + .portrait-pay-btn { + min-width: 80px; + } + + .portrait-send-btn { + min-width: 80px; + } + } + + /* ---- BOTTOM TAB BAR ---- */ + .portrait-tab-bar { + background: #fff; + border-top: 2px solid #e9ecef; + height: 60px; + + .portrait-tab-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: none; + background: transparent; + font-size: 0.68rem; + font-weight: 500; + color: #6c757d; + gap: 2px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + position: relative; + + i { + font-size: 1.15rem; + line-height: 1; + } + + span { + line-height: 1; + } + + &.active { + color: #6610f2; + background: rgba(102, 16, 242, 0.06); + + i { color: #6610f2; } + + // Active indicator line at top + &::before { + content: ''; + position: absolute; + top: 0; + left: 20%; + right: 20%; + height: 3px; + background: #6610f2; + border-radius: 0 0 3px 3px; + } + } + + &:hover:not(.active) { + background: rgba(0, 0, 0, 0.04); + color: #495057; + } + } + } + + /* ---- CART BADGE (item count on Cart tab) ---- */ + .portrait-badge { + position: absolute; + top: 6px; + right: calc(50% - 16px); + background: #6610f2; + color: #fff; + font-size: 0.6rem; + font-weight: 700; + min-width: 16px; + height: 16px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + line-height: 1; + } + } + + /* ============================================================ + CATEGORY SELECTOR — compact chips on portrait + ============================================================ */ + .category-selector { + .category-list { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + gap: 4px !important; + padding: 4px 6px !important; + + button { + font-size: 0.75rem !important; + padding: 4px 10px !important; + white-space: nowrap; + flex-shrink: 0; + } + } + } + + /* ============================================================ + ORDER LINES — compact for small screen + ============================================================ */ + .orderline { + padding: 6px 8px !important; + + .product-name { + font-size: 0.82rem !important; + } + + .product-price { + font-size: 0.82rem !important; + } + + .qty { + font-size: 0.85rem !important; + } + } + + /* ============================================================ + ORDER SUMMARY TOTAL ROW + ============================================================ */ + .order-summary { + font-size: 0.85rem !important; + + .fs-3 { + font-size: 1.1rem !important; + } + } +}