Mobile

ScanToAdd

Scan a product's code to add it to a list, by the camera, a photo or typing, with a beep, a haptic and a count of repeats.

#mobile#interactive#camera#barcode#commerce#tablet

Demo

Preview

Scan to add

Point the camera at a barcode, or type it below.

Nothing scanned yet.

Nothing yet.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/scan_to_add.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/scan_to_add.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/scan_to_add.qwe
let scan_to_add_demo_catalog = [    { code: "4006381333931", name: "Marker pen", price: "1.90" },    { code: "5901234123457", name: "Notebook, A5", price: "4.25" },    { code: "0012345678905", name: "Sticky notes", price: "2.10" },    { code: "96385074", name: "Eraser", price: "0.80" },]component ScanToAddDemoLive() {    state last = "Nothing yet."    <div.demo-stack>        <ScanToAdd catalog={scan_to_add_demo_catalog} gap={600} on_scan={(c, how) => { last = `${c} by ${how}` }}/>        <p.demo-note id="scan-demo-last">{last}</p>    </div>}view ScanToAddDemo() = <ScanToAddDemoLive/>

Source

ui/scan_to_add.qwe
// ScanToAdd: scan a product's barcode to add it to a list: by the camera, from a photo,// or typed. Each code that counts gives a beep and a haptic, and the same code again adds// one to its row, so the list shows how many of each. A code held in view counts once.//// iOS has no BarcodeDetector, so the camera's picture is also read here, in Quillo:// EAN-13, EAN-8 and UPC-A, the codes on products, from the rows of each frame. Where the// browser has BarcodeDetector that is used first. A camera refused or missing says so,// with Settings in the phone apps, and the photo and typing still work.////   import ui/scan_to_add.qwe use ScanToAdd////   <ScanToAdd catalog={[{ code: "4006381333931", name: "Marker pen", price: "1.90" }]}//              on_change={(rows) => { list = rows }}/>//// catalog: the products: each { code, name, price }. A code not in it is still added, as// "Not in the catalog". UPC-A and EAN-13 are one number, so 012345678905 finds 0012345678905.// title: the heading ("Scan to add"). id: a prefix for its ids, unique on the page ("ui-scanadd"). gap: ms a code must leave the view before it// counts again (1500). sound: starts on (true); the person can turn it off. currency ("USD"),// locale. on_change(rows): after each change, each row { code, name, qty, known, price }.// on_scan(code, how): each code that counted; how is "camera", "photo" or "typed".//// scan_to_add_row(luma) reads a line of an image, each pixel's brightness 0 to 255, and// gives the digits or null. scan_to_add_read(text) checks a typed code.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/price.qwe use price_textlet scan_to_add_css = css`.ui-scanadd { display: grid; gap: 12px; min-width: 0; container-type: inline-size; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-scanadd h3 { margin: 0; font-size: 17px; }.ui-scanadd-cols { display: grid; gap: 12px; min-width: 0; }.ui-scanadd-col { display: grid; gap: 12px; min-width: 0; align-content: start; }.ui-scanadd-view {  position: relative; display: grid; place-items: center; min-height: 140px; overflow: hidden; border-radius: var(--ui-radius); background: #0f0e17; color: #fff; aspect-ratio: 16 / 10;}.ui-scanadd-view video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }.ui-scanadd-aim { position: absolute; inset: 22% 8%; border: 2px solid rgba(255, 255, 255, .85); border-radius: 8px; pointer-events: none; }.ui-scanadd-aim::after { content: ""; position: absolute; left: 6px; right: 6px; top: 50%; height: 2px; background: #ff5a5a; }.ui-scanadd-msg { position: relative; margin: 0; padding: 12px 16px; text-align: center; }.ui-scanadd-acts { display: flex; flex-wrap: wrap; gap: 8px; }.ui-scanadd-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; margin: 0; }.ui-scanadd-form label { display: grid; gap: 2px; min-width: 0; color: var(--ui-muted); font-size: 13px; }/* 16 px: anything smaller and iOS zooms the page when the field is touched. */.ui-scanadd-form input {  box-sizing: border-box; width: 100%; height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 16px var(--ui-font);}.ui-scanadd-form input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-scanadd-said { min-height: 20px; margin: 0; font-weight: 600; }.ui-scanadd-bad { color: var(--ui-danger); }.ui-scanadd-rows { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-scanadd-row {  display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 6px 4px 6px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle);}.ui-scanadd-name { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }.ui-scanadd-code { display: block; color: var(--ui-muted); font: 12px var(--ui-mono); }.ui-scanadd-unknown { display: block; color: var(--ui-warning); font-size: 12px; font-weight: 400; }.ui-scanadd-qty { display: inline-flex; align-items: center; }.ui-scanadd-step {  display: grid; place-items: center; min-width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg);  font: 500 20px var(--ui-font); cursor: pointer; touch-action: manipulation;}.ui-scanadd-step:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-scanadd-n { min-width: 34px; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }.ui-scanadd-sum { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ui-muted); }.ui-scanadd-empty { margin: 0; color: var(--ui-muted); }@container (min-width: 600px) { .ui-scanadd-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; align-items: start; } }`// EAN's digits as the widths of their space, bar, space, bar, in modules. The left half's// other form (G) is the same read backwards, and the right half has these with the colours// turned round, which gives the same widths.let scan_to_add_widths = [[3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2],                          [1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2]]// Which forms the six left digits of an EAN-13 take gives its first digit.let scan_to_add_first = ["LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG", "LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL"]// The GS1 check digit of the digits before it.fn scan_to_add_check(body: list) -> int {    let n = len(body)    let total = enumerate(body) | reduce(0, (a, p) => a + p[1] * ((n - p[0]) % 2 == 1 ? 3 : 1))    (10 - total % 10) % 10}fn scan_to_add_digit_list(s: str) -> list = split(s, "") | map((c) => int(c))// A thing typed: { code, error }. Spaces and hyphens are fine. code is the digits;// error says what is wrong, naming the digit that would be right.fn scan_to_add_read(text) -> map {    let t = re.replace(slice(str(text ?? ""), 0, 60), "[ -]", "")    if t == "" { return { code: null, error: "Type a code first." } }    if !re.test(t, "^[0-9]{1,40}$") { return { code: t, error: "free" } }    if !contains([8, 12, 13], len(t)) { return { code: null, error: "A product code has 8, 12 or 13 digits." } }    let d = scan_to_add_digit_list(t)    let right = scan_to_add_check(slice(d, 0, len(d) - 1))    d[len(d) - 1] == right ? { code: t, error: null } : { code: null, error: `That code does not check: its last digit should be ${right}.` }}// UPC-A is an EAN-13 with a 0 in front: the same product, however it was read.fn scan_to_add_key(code: str) -> str = len(code) == 12 && re.test(code, "^[0-9]{12}$") ? "0" + code : code// Which digit four widths are, against a list of patterns: { d, off }, off being how far// they are from it. 7 modules to a digit, so the widths are scaled to that first.fn scan_to_add_match(w: list, flip: bool) -> map {    let unit = 7 / max([1, sum(w)])    let scaled = w | map((x) => x * unit)    let tries = for [d, p] in enumerate(scan_to_add_widths) {        let pat = flip ? reverse(p) : p        { d: d, off: abs(scaled[0] - pat[0]) + abs(scaled[1] - pat[1]) + abs(scaled[2] - pat[2]) + abs(scaled[3] - pat[3]) }    }    sort_by(tries, (t) => t.off)[0]}// A run of a guard: each width near one module.fn scan_to_add_guard(runs: list, at: int, n: int, unit) -> bool =    range(at, at + n) | all((i) => runs[i] > unit * 0.4 && runs[i] < unit * 2)// Reads a code starting at a bar: `half` digits a side, EAN-13 with 6 and EAN-8 with 4.fn scan_to_add_at(runs: list, start: int, half: int) {    let count = 3 + half * 4 + 5 + half * 4 + 3    if start + count > len(runs) { return null }    let span = slice(runs, start, start + count)    let unit = sum(span) / (half == 6 ? 95 : 67)    if !scan_to_add_guard(runs, start, 3, unit) { return null }    if start > 0 && runs[start - 1] < unit * 3 { return null }    let middle = start + 3 + half * 4    if !scan_to_add_guard(runs, middle, 5, unit) || !scan_to_add_guard(runs, middle + 5 + half * 4, 3, unit) { return null }    let left = for i in range(0, half) {        let w = slice(runs, start + 3 + i * 4, start + 7 + i * 4)        let plain = scan_to_add_match(w, false)        let other = scan_to_add_match(w, true)        half == 4 || plain.off <= other.off ? { d: plain.d, form: "L", off: plain.off } : { d: other.d, form: "G", off: other.off }    }    let right = for i in range(0, half) => scan_to_add_match(slice(runs, middle + 5 + i * 4, middle + 9 + i * 4), false)    if (left + right) | any((x) => x.off > 1.5) { return null }    let digits = (left | map((x) => x.d)) + (right | map((x) => x.d))    let all_digits = if half == 4 { digits } else {        let first = scan_to_add_first | index_of(left | map((x) => x.form) | join(""))        if first == null { return null }        [first] + digits    }    let body = slice(all_digits, 0, len(all_digits) - 1)    all_digits[len(all_digits) - 1] == scan_to_add_check(body) ? all_digits | join("") : null}// The widths of the light and dark runs along a line, the first being dark: { runs, dark }.// Light falls unevenly across a phone's picture, so each pixel is judged against the// brightest and darkest near it, by blocks of a twentieth of the line.fn scan_to_add_runs(luma: list) -> list {    let n = len(luma)    let block = max([8, floor(n / 20)])    let count = ceil(n / block)    let parts = for i in range(0, count) => slice(luma, i * block, (i + 1) * block)    let lows = parts | map((p) => min(p))    let highs = parts | map((p) => max(p))    let whole = (min(lows) + max(highs)) / 2    let cuts = for i in range(0, count) {        let a = max([0, i - 1])        let b = min([count, i + 2])        let lo = min(slice(lows, a, b))        let hi = max(slice(highs, a, b))        hi - lo < 40 ? whole : (lo + hi) / 2    }    // Start at the first dark pixel, then the widths by turns: dark, light, dark.    let runs = []    let width = 0    let dark = null    for [i, v] in enumerate(luma) {        let now_dark = v < cuts[floor(i / block)]        if dark == null {            if now_dark {                dark = true                width = 1            }        } else if now_dark == dark {            width += 1        } else {            runs = runs | push(width)            width = 1            dark = now_dark        }    }    runs}// The code along one line, read each way, or null.fn scan_to_add_row(luma: list) {    if len(luma) < 95 { return null }    let runs = scan_to_add_runs(luma)    for way in [runs, reverse(runs)] {        let tops = range(0, floor(max([0, len(way) - 42]) / 2) + 1) | map((i) => i * 2)        for start in tops {            let long = scan_to_add_at(way, start, 6)            if long != null { return long }            let short = scan_to_add_at(way, start, 4)            if short != null { return short }        }    }    null}// Whether a code seen counts: not the same one again before it has left the view. The// time of the last sighting moves with each sighting, so one held still counts once.fn scan_to_add_fresh(last, code: str, t, gap: int) -> bool = last == null || last.code != code || t - last.at >= gap// A list with one more of a code: at the top if it is new, in its place if not.fn scan_to_add_added(rows: list, code: str, catalog: list) -> list {    let k = scan_to_add_key(code)    if rows | any((r) => r.key == k) { return rows | map((r) => r.key == k ? set(r, "qty", r.qty + 1) : r) }    let item = catalog | find((c) => type_of(c) == "map" && scan_to_add_key(str(get(c, "code") ?? "")) == k)    [{ key: k, code: code, name: item != null ? str(get(item, "name") ?? code) : code, known: item != null,       price: item != null ? get(item, "price") : null, qty: 1 }] + rows}// What a row comes to in all: rows with no price count nothing.fn scan_to_add_total(rows: list) {    rows | reduce(dec("0"), (a, r) => {        let p = r.price != null ? dec(r.price) : null        p == null || is_err(p) ? a : a + p * r.qty    })}component ScanToAdd(catalog: list = [], title: str = "Scan to add", id: str = "ui-scanadd", gap: int = 1500, sound: bool = true, currency: str = "USD", locale: str? = null,                    on_change = null, on_scan = null) {    style global scan_to_add_css    state rows = []    state text = ""    state said = ""    state bad = false    state camera = "off"     // off, starting, live, none (there is none), refused, paused (the app left the screen)    state stream = null    state video = null    state picker = null    state canvas = null    state detector = null    // the browser's BarcodeDetector, where there is one    state busy = false       // a frame is being read    state last = null        // the last code seen: { code, at }    state beeps = sound    state audio = null    state asked = false      // Clear pressed once    state native = false     // in a phone app, which can open Settings    let told = () => { on_change && on_change(rows) }    let stop = () => {        if stream != null {            for t in js("Array.from", stream.getTracks()) { t.stop() }            stream = null        }    }    let feel = (good) => {        let w = js("window")        if w.quilloNative != null {            js("quilloNative.haptic", good ? "success" : "warning")        } else if js("navigator").vibrate != null {            js("navigator").vibrate(good ? 30 : [40, 60, 40])        }    }    let beep = (good) => {        if beeps && audio != null {            let o = audio.createOscillator()            let g = audio.createGain()            o.frequency.value = good ? 1480 : 330            g.gain.value = 0.08            o.connect(g)            g.connect(audio.destination)            o.start()            o.stop(audio.currentTime + (good ? 0.09 : 0.22))        }    }    // A page may sound only after a tap: each way in makes it here, from one.    let wake_sound = () => {        if audio == null {            let w = js("window")            let make = w.AudioContext != null ? js_new("AudioContext") : (w.webkitAudioContext != null ? js_new("webkitAudioContext") : null)            if make != null && !is_err(make) { audio = make }        }        if audio != null && audio.state == "suspended" { audio.resume() }    }    let tally = (code, how) => {        rows = scan_to_add_added(rows, code, catalog)        let row = rows | find((r) => r.key == scan_to_add_key(code))        said = row.qty > 1 ? `${row.name}: ${row.qty}` : `${row.name} added`        bad = false        asked = false        beep(true)        feel(true)        told()        on_scan && on_scan(code, how)    }    let seen = (raw, how) => {        let r = scan_to_add_read(raw)        let known = catalog | any((c) => type_of(c) == "map" && str(get(c, "code") ?? "") == r.code)        if r.error == null || (r.error == "free" && known) {            let t = now()            let fresh = how != "camera" || scan_to_add_fresh(last, r.code, t, gap)            last = { code: r.code, at: t }            if fresh { tally(r.code, how) }        } else if how != "camera" {            said = r.error == "free" ? "That is not a product code." : r.error            bad = true            beep(false)            feel(false)        }    }    let read_frame = (source, wide, high) => {        let d = js("document")        if canvas == null { canvas = d.createElement("canvas") }        let w = min([wide, 1024])        let h = round(high * w / wide)        canvas.width = w        canvas.height = h        let c = canvas.getContext("2d", { willReadFrequently: true })        c.drawImage(source, 0, 0, w, h)        let found = null        for part in [0.5, 0.42, 0.58, 0.34, 0.66] {            if found == null {                let px = js("Array.from", c.getImageData(0, floor(h * part), w, 1).data)                let luma = for i in range(0, w) => px[i * 4] * 0.3 + px[i * 4 + 1] * 0.59 + px[i * 4 + 2] * 0.11                found = scan_to_add_row(luma)            }        }        // A phone held across the code: the same again, down the middle.        if found == null {            let px = js("Array.from", c.getImageData(floor(w / 2), 0, 1, h).data)            found = scan_to_add_row(for i in range(0, h) => px[i * 4] * 0.3 + px[i * 4 + 1] * 0.59 + px[i * 4 + 2] * 0.11)        }        found    }    let tick = () => {        if camera == "live" && !busy && video != null && video.readyState >= 2 && video.videoWidth > 0 {            if detector != null {                busy = true                detector.detect(video).then(                    (found) => {                        busy = false                        if len(found) > 0 { seen(found[0].rawValue, "camera") }                    },                    (_e = null) => { busy = false })            } else {                let code = read_frame(video, video.videoWidth, video.videoHeight)                if code != null { seen(code, "camera") }            }        }    }    if camera == "live" { every(240, tick) }    let start = () => {        wake_sound()        let md = js("navigator").mediaDevices        if md == null || md.getUserMedia == null {            camera = "none"        } else {            camera = "starting"            let w = js("window")            detector = null            if w.BarcodeDetector != null {                // It throws for a format the device lacks: then the reading here does the work.                let made = js("Promise.resolve", js_new("Promise", (done) => { done(js_new("BarcodeDetector", { formats: ["ean_13", "ean_8", "upc_a"] })) }))                if !is_err(made) { detector = made }            }            md.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 } }, audio: false }).then(                (s) => {                    stream = s                    camera = "live"                    video.srcObject = s                    video.play().catch((_ = null) => { busy = false })                },                (e = null) => {                    native = w.quilloNative != null                    camera = e.name == "NotAllowedError" || e.name == "SecurityError" ? "refused" : "none"                })        }    }    let quit = () => {        stop()        camera = "off"    }    let photo = (file) => {        wake_sound()        if js("window").createImageBitmap == null {            said = "This browser cannot read a photo. Type the code."            bad = true        } else {          js("createImageBitmap", file).then(            (b) => {                let code = read_frame(b, b.width, b.height)                b.close()                if code != null {                    seen(code, "photo")                } else {                    said = "No code found in that photo. Try closer, with the code straight."                    bad = true                    beep(false)                    feel(false)                }            },            (_e = null) => {                said = "That photo could not be read."                bad = true            })        }    }    let total = scan_to_add_total(rows)    let many = rows | reduce(0, (n, r) => n + r.qty)    <div.ui-scanadd         @mount={(_el) => {             let d = js("document")             // iOS stops the camera when the app leaves the screen, and it does not come back alone.             let away = (_e = null) => {                 if d.visibilityState == "hidden" {                     if camera == "live" {                         stop()                         camera = "paused"                     }                 } else if camera == "paused" {                     start()                 }             }             d.addEventListener("visibilitychange", away)             () => {                 d.removeEventListener("visibilitychange", away)                 stop()             }         }}>        <h3>{title}</h3>        <div.ui-scanadd-cols>            <div.ui-scanadd-col>                <div.ui-scanadd-view>                    <video muted={true} playsinline={true} autoplay={true} hidden={camera != "live"} aria-label="The camera's picture"                           @mount={(el) => {                               // Set here as well: the attribute only starts it muted, and iOS plays inline only if muted.                               el.muted = true                               video = el                           }}></video>                    {camera == "live" && <div.ui-scanadd-aim aria-hidden="true"></div>}                    {camera != "live" && <p.ui-scanadd-msg role="status">{match camera {                        "starting" => "Asking for the camera…"                        "none" => "This device has no camera this page can use. Take a photo, or type the code."                        "refused" => native ? "The camera was refused. Turn it on in Settings, or type the code." : "The camera was refused. Allow it in your browser's settings, or type the code."                        "paused" => "The camera stopped when you left."                        _ => "Point the camera at a barcode, or type it below."                    }}</p>}                </div>                <div.ui-scanadd-acts>                    {camera == "live"                        ? <Button variant="outline" size="lg" on_click={quit}>Stop camera</Button>                        : <Button size="lg" on_click={start}>Start camera</Button>}                    {camera == "refused" && native && <Button variant="outline" size="lg" on_click={() => { js("quilloNative.openSettings") }}>Open Settings</Button>}                    <Button variant="outline" size="lg" on_click={() => { picker.click() }}>Photo of a code</Button>                    <input type="file" accept="image/*" capture="environment" hidden={true} aria-label="Photo of a code"                           @mount={(el) => { picker = el }}                           @change={(e) => {                               let f = e.target.files[0]                               if f != null { photo(f) }                               e.value = ""                           }}/>                    <Button variant="ghost" size="lg" on_click={() => { beeps = !beeps; wake_sound() }}>{beeps ? "Sound on" : "Sound off"}</Button>                </div>                <form.ui-scanadd-form @submit={(e) => {                    e.preventDefault()                    wake_sound()                    let was = text                    seen(was, "typed")                    if !bad { text = "" }                }}>                    <label for={`${id}-code`}>Code                        <input id={`${id}-code`} inputmode="numeric" autocomplete="off" enterkeyhint="go" value={text}                               @input={(e) => { text = e.value }}/>                    </label>                    <Button type="submit" size="lg">Add</Button>                </form>                <p class={ui_cls(["ui-scanadd-said", bad && "ui-scanadd-bad"])} role="status">{said}</p>            </div>            <div.ui-scanadd-col>                {len(rows) == 0                    ? <p.ui-scanadd-empty>Nothing scanned yet.</p>                    : <ul.ui-scanadd-rows>                        {for r in rows => <li.ui-scanadd-row key={r.key}>                            <div.ui-scanadd-name>                                {r.name}                                <span.ui-scanadd-code>{r.code}</span>                                {!r.known && <span.ui-scanadd-unknown>Not in the catalog</span>}                            </div>                            <div.ui-scanadd-qty role="group" aria-label={`How many of ${r.name}`}>                                <button.ui-scanadd-step type="button" aria-label={r.qty == 1 ? `Remove ${r.name}` : `One fewer ${r.name}`}                                        @click={() => {                                            rows = r.qty == 1 ? rows | filter((x) => x.key != r.key) : rows | map((x) => x.key == r.key ? set(x, "qty", x.qty - 1) : x)                                            told()                                        }}>−</button>                                <span.ui-scanadd-n aria-label={`${r.qty} scanned`}>{r.qty}</span>                                <button.ui-scanadd-step type="button" aria-label={`One more ${r.name}`}                                        @click={() => {                                            rows = rows | map((x) => x.key == r.key ? set(x, "qty", x.qty + 1) : x)                                            told()                                        }}>+</button>                            </div>                        </li>}                    </ul>}                {len(rows) > 0 && <p.ui-scanadd-sum>                    <span>{many} scanned, {len(rows)} different{total > 0 ? `, ${price_text(total, currency, locale)}` : ""}</span>                    <Button variant="ghost" size="lg" on_click={() => {                        if asked {                            rows = []                            asked = false                            said = ""                            told()                        } else {                            asked = true                        }                    }}>{asked ? "Clear them all?" : "Clear"}</Button>                </p>}            </div>        </div>    </div>}
Thread
37Useful? Like it, or add to its thread below.

Thread 0 posts

On ScanToAdd: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.