diff --git a/addons/point_of_sale/static/src/app/customer_display_service.js b/addons/point_of_sale/static/src/app/customer_display_service.js new file mode 100644 index 00000000000..6555b1be8de --- /dev/null +++ b/addons/point_of_sale/static/src/app/customer_display_service.js @@ -0,0 +1,146 @@ +/** @odoo-module */ + +import { effect, Reactive } from "@point_of_sale/utils"; +import { registry } from "@web/core/registry"; +import { pick } from "@web/core/utils/objects"; + +export class LocalDisplay extends Reactive { + status = "success"; + constructor() { + super(); + this.setup(...arguments); + } + setup(globalState) { + this.globalState = globalState; + } + async connect() { + this.popupWindow = window.open("", "Customer Display", "height=600,width=900"); + this.update({ refreshResources: true }); + } + async update({ refreshResources = false } = {}) { + if (!this.popupWindow || this.popupWindow.closed) { + return; + } + // TODO: this could probably be improved by loading owl in the popup window, + // making it render the customer display, and simply sending messages to + // update the state, instead of sending HTML which causes jankiness with + // animations. + const { body: displayBody, head: displayHead } = this.popupWindow.document; + const container = document.createElement("div"); + container.innerHTML = await this.globalState.customerDisplayHTML(); + + if (refreshResources) { + displayHead.textContent = ""; + displayHead.appendChild(container.querySelector(".resources")); + } + + displayBody.textContent = ""; + displayBody.appendChild(container.querySelector(".pos-customer_facing_display")); + + const orderLines = displayBody.querySelector(".pos_orderlines_list"); + orderLines.scrollTop = orderLines.scrollHeight; + } +} + +export class RemoteDisplay extends Reactive { + static serviceDependencies = ["hardware_proxy"]; + status = "failure"; + constructor() { + super(); + this.setup(...arguments); + } + setup(globalState, { hardware_proxy }) { + this.hardwareProxy = hardware_proxy; + this.globalState = globalState; + this.updateStatus(); + } + async connect() { + const html = await this.globalState.customerDisplayHTML(); + try { + const { status } = await this.hardwareProxy.message("take_control", { html }); + this.status = status === "success" ? "success" : "warning"; + this.updateStatus(); + } catch (error) { + this.status = error === undefined ? "failure" : "not_found"; + } + } + async update() { + const html = await this.globalState.customerDisplayHTML(); + if (this.isUpdatingStatus && this.hardwareProxy.connectionInfo.status === "connected") { + return this.hardwareProxy.message("customer_facing_display", { html }); + } + } + async updateStatus() { + if (!this.hardwareProxy.host || this.isUpdatingStatus) { + return; + } + this.isUpdatingStatus = true; + while (this.isUpdatingStatus) { + try { + const { status } = await this.hardwareProxy.message("test_ownership"); + this.status = status === "OWNER" ? "success" : "warning"; + } catch (error) { + if (error === undefined) { + // FIXME POSREF when does this happen? + this.status = "failure"; + } else { + this.status = "not_found"; + this.isUpdatingStatus = false; + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } +} + +export const customerDisplayService = { + get dependencies() { + // getter so thet the RemoteDisplay dependencies are resolved when services start + // and not at module definition time, this allows patches to be accounted for + return ["pos", ...RemoteDisplay.serviceDependencies]; + }, + start(env, deps) { + const { globalState } = deps.pos; + + // Dummy service, does nothing until the globalState is ready, then the methods get replaced + const service = { + connect() {}, + }; + + globalState.ready.then(() => { + const { + iface_customer_facing_display: enabled, + iface_customer_facing_display_local: local, + iface_customer_facing_display_via_proxy: proxy, + } = globalState.config; + if (!enabled) { + return; + } + + let display; + if (local) { + display = new LocalDisplay(globalState); + } else if (proxy) { + display = new RemoteDisplay( + globalState, + pick(deps, ...RemoteDisplay.serviceDependencies) + ); + } else { + // FIXME POSREF can this ever happen? + return; + } + // FIXME POSREF this is very inelegant. There are a lot of places that get more complicated + // because we want to mount the chrome before the globalState is loaded. Maybe we should + // just make the loading screen its own thing that doesn't depend on anything, instead + // of having it be a part of the chrome and having to propagate complexity everywhere. + env.services.customer_display = display; + // Register an effect to update the display automatically when anything it renders changes + effect((display) => display.update(), [display]); + }); + + return service; + }, +}; + +registry.category("services").add("customer_display", customerDisplayService); diff --git a/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js b/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js index 5fc28e93d52..840f5e1c80f 100644 --- a/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js +++ b/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js @@ -23,7 +23,6 @@ export class HardwareProxy extends EventBus { this.useDebugWeight = false; this.host = ""; this.keptalive = false; - this.customerDisplayAvailable = true; this.connectionInfo = reactive({ status: "disconnected", drivers: {} }); effect( (info) => { @@ -191,27 +190,6 @@ export class HardwareProxy extends EventBus { this.debugWeight = 0; } - updateCustomerDisplay(html) { - if (this.customerDisplayAvailable && this.connectionInfo.status == "connected") { - return this.message("customer_facing_display", { html }, { timeout: 5000 }); - } - } - - /** - * @param {string} html - * @returns {Promise} - */ - takeControlOfCustomerDisplay(html) { - return this.message("take_control", { html }); - } - - /** - * @returns {Promise} - */ - testOwnershipOfCustomerDisplay() { - return this.host ? this.message("test_ownership") : Promise.reject({ abort: true }); - } - // asks the proxy to log some information, as with the debug.log you can provide several arguments. log() { return this.message("log", { arguments: [...arguments] }); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js index 32426144fb3..bc9f998011c 100644 --- a/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js @@ -2,101 +2,21 @@ import { Component, useState } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; +import { usePos } from "@point_of_sale/app/pos_hook"; +import { _t } from "@web/core/l10n/translation"; export class CustomerFacingDisplayButton extends Component { static template = "CustomerFacingDisplayButton"; - setup() { - super.setup(); - this.hardwareProxy = useService("hardware_proxy"); - this.local = - this.env.pos.config.iface_customer_facing_display_local && - !this.env.pos.config.iface_customer_facing_display_via_proxy; - this.state = useState({ status: this.local ? "success" : "failure" }); - this._start(); + this.pos = usePos(); + this.customerDisplay = useState(useService("customer_display")); } get message() { return { success: "", - warning: this.env._t("Connected, Not Owned"), - failure: this.env._t("Disconnected"), - not_found: this.env._t("Customer Screen Unsupported. Please upgrade the IoT Box"), - }[this.state.status]; - } - onClick() { - if (this.local) { - return this.onClickLocal(); - } else { - return this.onClickProxy(); - } - } - async onClickLocal() { - this.env.pos.customer_display = window.open("", "Customer Display", "height=600,width=900"); - const renderedHtml = await this.env.pos.render_html_for_customer_facing_display(); - var $renderedHtml = $("
").html(renderedHtml); - $(this.env.pos.customer_display.document.body).html( - $renderedHtml.find(".pos-customer_facing_display") - ); - $(this.env.pos.customer_display.document.head).html( - $renderedHtml.find(".resources").html() - ); - } - async onClickProxy() { - try { - const renderedHtml = await this.env.pos.render_html_for_customer_facing_display(); - let ownership = await this.hardwareProxy.takeControlOfCustomerDisplay(renderedHtml); - if (typeof ownership === "string") { - ownership = JSON.parse(ownership); - } - if (ownership.status === "success") { - this.state.status = "success"; - } else { - this.state.status = "warning"; - } - if (!this.hardwareProxy.customerDisplayAvailable) { - this.hardwareProxy.customerDisplayAvailable = true; - this._start(); - } - } catch (error) { - if (typeof error == "undefined") { - this.state.status = "failure"; - } else { - this.state.status = "not_found"; - } - } - } - _start() { - if (this.local) { - return; - } - - const loop = async () => { - if (this.hardwareProxy.customerDisplayAvailable) { - try { - let ownership = await this.hardwareProxy.testOwnershipOfCustomerDisplay(); - if (typeof ownership === "string") { - ownership = JSON.parse(ownership); - } - if (ownership.status === "OWNER") { - this.state.status = "success"; - } else { - this.state.status = "warning"; - } - } catch (error) { - if (error.abort) { - // Stop the loop - return; - } - if (typeof error == "undefined") { - this.state.status = "failure"; - } else { - this.state.status = "not_found"; - this.hardwareProxy.customerDisplayAvailable = false; - } - } - setTimeout(loop, 3000); - } - }; - loop(); + warning: _t("Connected, Not Owned"), + failure: _t("Disconnected"), + not_found: _t("Customer Screen Unsupported. Please upgrade the IoT Box"), + }[this.customerDisplay.status]; } } diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index fafb60f846b..857e6926caa 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -15,17 +15,41 @@ import { markRaw, reactive } from "@odoo/owl"; import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup"; import { escape } from "@web/core/utils/strings"; import { Mutex } from "@web/core/utils/concurrency"; +import { memoize } from "@web/core/utils/functions"; +import { _t } from "@web/core/l10n/translation"; +import { renderToString } from "@web/core/utils/render"; var QWeb = core.qweb; -var _t = core._t; var round_di = utils.round_decimals; var round_pr = utils.round_precision; const Markup = utils.Markup; -// Container of the product images fetched during rendering -// of customer display. There is no need to observe it, thus, -// we are putting it outside of PosGlobalState. -const PRODUCT_ID_TO_IMAGE_CACHE = {}; +/** + * Gets a product image as a base64 string so that it can be sent to the + * customer display, as the display won't be able to fetch it, since the image + * controller requires the client to be logged. This function is memoized on the + * product id, so that we will only do this once per product. + * + * @param {number} productId id of the product + * @param {string} writeDate the write date of the product, used as a cache + * buster in case the product image has been changed + * @returns {string} the base64 representation of the product's image + */ +const getProductImage = memoize(function getProductImage(productId, writeDate) { + return new Promise(function (resolve, reject) { + const img = new Image(); + img.addEventListener("load", () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + canvas.height = img.height; + canvas.width = img.width; + ctx.drawImage(img, 0, 0); + resolve(canvas.toDataURL("image/jpeg")); + }); + img.addEventListener("error", reject); + img.src = `/web/image?model=product.product&field=image_128&id=${productId}&unique=${writeDate}`; + }); +}); /** * If optimization is needed, then we should implement this @@ -919,105 +943,31 @@ export class PosGlobalState extends PosModel { return this.orders; } - _convert_product_img_to_base64(product, url) { - return new Promise(function (resolve, reject) { - var img = new Image(); - - img.onload = function () { - var canvas = document.createElement("CANVAS"); - var ctx = canvas.getContext("2d"); - - canvas.height = this.height; - canvas.width = this.width; - ctx.drawImage(this, 0, 0); - - var dataURL = canvas.toDataURL("image/jpeg"); - canvas = null; - - resolve([product, dataURL]); - }; - img.crossOrigin = "use-credentials"; - img.src = url; - }); - } - - get customer_display() { - return this.unwatched.customer_display; - } - - set customer_display(value) { - this.unwatched.customer_display = markRaw(value); - } - - send_current_order_to_customer_facing_display() { - var self = this; - if (!this.config.iface_customer_facing_display) { + /** + * Renders the HTML for the customer display and returns it as a string. + * + * @returns {string} + */ + async customerDisplayHTML() { + const order = this.get_order(); + if (!order) { return; } - this.render_html_for_customer_facing_display().then((rendered_html) => { - if (self.env.pos.customer_display) { - var $renderedHtml = $("
").html(rendered_html); - $(self.env.pos.customer_display.document.body).html( - $renderedHtml.find(".pos-customer_facing_display") - ); - var orderlines = $(self.env.pos.customer_display.document.body).find( - ".pos_orderlines_list" - ); - orderlines.scrollTop(orderlines.prop("scrollHeight")); - } else if ( - this.config.iface_customer_facing_display_via_proxy && - this.hardwareProxy.customerDisplayAvailable - ) { - this.hardwareProxy.updateCustomerDisplay(rendered_html); - } - }); - } + const orderLines = order.get_orderlines(); + const productImages = Object.fromEntries( + await Promise.all( + orderLines.map(async ({ product }) => [ + product.id, + await getProductImage(product.id, product.writeDate), + ]) + ) + ); - /** - * @returns {Promise} - */ - render_html_for_customer_facing_display() { - var self = this; - var order = this.get_order(); - - // If we're using an external device like the IoT Box, we - // cannot get /web/image?model=product.product because the - // IoT Box is not logged in and thus doesn't have the access - // rights to access product.product. So instead we'll base64 - // encode it and embed it in the HTML. - var get_image_promises = []; - - if (order) { - order.get_orderlines().forEach(function (orderline) { - var product = orderline.product; - var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`; - - // only download and convert image if we haven't done it before - if (!(product.id in PRODUCT_ID_TO_IMAGE_CACHE)) { - get_image_promises.push( - self._convert_product_img_to_base64(product, image_url) - ); - } - }); - } - - return Promise.all(get_image_promises).then(function (productIdImagePairs) { - for (const [product, image] of productIdImagePairs) { - PRODUCT_ID_TO_IMAGE_CACHE[product.id] = image; - } - // Collect the product images that will be used in rendering the customer display template. - const productImages = {}; - if (order) { - for (const line of order.get_orderlines()) { - productImages[line.product.id] = PRODUCT_ID_TO_IMAGE_CACHE[line.product.id]; - } - } - return QWeb.render("CustomerFacingDisplayOrder", { - pos: self, - origin: window.location.origin, - order: order, - productImages, - }); + return renderToString("CustomerFacingDisplayOrder", { + pos: this, + origin: window.location.origin, + order, + productImages, }); } diff --git a/addons/point_of_sale/static/src/scss/pos.scss b/addons/point_of_sale/static/src/scss/pos.scss index b3f4b29e399..5b9d70ac8af 100644 --- a/addons/point_of_sale/static/src/scss/pos.scss +++ b/addons/point_of_sale/static/src/scss/pos.scss @@ -220,6 +220,7 @@ td { height: 0px; } .pos .subwindow .subwindow-container{ + height: 100%; display: table-cell; position: relative; } @@ -1440,6 +1441,7 @@ td { height:100%; } .pos .screen .content-cell{ + height: 100%; width:100%; } .pos .screen .content-cell .content-container{ diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml index 686c7cbe86e..68d210ce50a 100644 --- a/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml @@ -2,15 +2,15 @@ -
+
-
+
-
+
-
+
diff --git a/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml b/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml index b307b6c7648..518c8f87226 100644 --- a/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml +++ b/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml @@ -1,18 +1,18 @@ - +
- +
- + @@ -49,7 +49,7 @@
- +
@@ -59,7 +59,7 @@
-
+
@@ -70,10 +70,10 @@
- +
- +
: