diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c16ed..d55d69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ # Changelog ## Unreleased +- **On-screen pointer to your card reader.** When a donor reaches the "Tap, insert or swipe" step, the + kiosk can now show a **pulsing contactless symbol with arrows pointing to it**, on the side of the + screen where your reader is mounted. When the payment goes through it turns **green**. Set it per + tablet in **Devices → Reader side** (Left / Right / **No hint** — the default, so nothing changes + until you choose a side). Left/right are for a landscape mount; on a portrait mount they follow the + rotation and become top/bottom automatically. - **Bigger, adjustable campaign tabs.** The tabs across the top of the kiosk (one per campaign, shown when you have more than one) now have a **kiosk-wide "Campaign tab size"** setting in **Campaigns → Kiosk settings** — Small / Medium / Large / Extra large. **Medium is the original diff --git a/android/app/src/main/java/org/openmasjidos/kiosk/local/DeviceStore.kt b/android/app/src/main/java/org/openmasjidos/kiosk/local/DeviceStore.kt index 08e3fad..912ee07 100644 --- a/android/app/src/main/java/org/openmasjidos/kiosk/local/DeviceStore.kt +++ b/android/app/src/main/java/org/openmasjidos/kiosk/local/DeviceStore.kt @@ -63,6 +63,7 @@ class DeviceStore(private val context: Context) { val CFG_FOOTER = stringPreferencesKey("cfg_footer") val CFG_TAB_SIZE = stringPreferencesKey("cfg_tab_size") val CFG_ORIENTATION = stringPreferencesKey("cfg_orientation") + val CFG_NFC_SIDE = stringPreferencesKey("cfg_nfc_side") val CFG_LARGE_THRESHOLD = longPreferencesKey("cfg_large_threshold") val CFG_LARGE_NOTE = stringPreferencesKey("cfg_large_note") val CFG_LARGE_IMAGE = stringPreferencesKey("cfg_large_image") @@ -113,6 +114,7 @@ class DeviceStore(private val context: Context) { footerText = p[Keys.CFG_FOOTER] ?: "OpenMasjid Solutions", tabSize = p[Keys.CFG_TAB_SIZE] ?: "medium", orientation = p[Keys.CFG_ORIENTATION] ?: "0", + nfcSide = p[Keys.CFG_NFC_SIDE] ?: "off", largeAmountThresholdMinor = p[Keys.CFG_LARGE_THRESHOLD] ?: 0L, largeAmountNote = p[Keys.CFG_LARGE_NOTE].orEmpty(), largeAmountImage = p[Keys.CFG_LARGE_IMAGE].orEmpty(), @@ -151,6 +153,7 @@ class DeviceStore(private val context: Context) { p[Keys.CFG_FOOTER] = config.footerText p[Keys.CFG_TAB_SIZE] = config.tabSize p[Keys.CFG_ORIENTATION] = config.orientation + p[Keys.CFG_NFC_SIDE] = config.nfcSide p[Keys.CFG_LARGE_THRESHOLD] = config.largeAmountThresholdMinor p[Keys.CFG_LARGE_NOTE] = config.largeAmountNote p[Keys.CFG_LARGE_IMAGE] = config.largeAmountImage diff --git a/android/app/src/main/java/org/openmasjidos/kiosk/local/Models.kt b/android/app/src/main/java/org/openmasjidos/kiosk/local/Models.kt index 2503730..dee562f 100644 --- a/android/app/src/main/java/org/openmasjidos/kiosk/local/Models.kt +++ b/android/app/src/main/java/org/openmasjidos/kiosk/local/Models.kt @@ -89,6 +89,10 @@ data class KioskConfig( * rotates its own content by this angle (RotatedRoot), so it works even on tablets that ignore * system orientation requests. Legacy named values are still accepted + mapped by orientationDegrees. */ val orientation: String = "0", + /** Which side of the tablet the card reader sits on: "off" | "left" | "right". Drives the on-screen + * reader hint (pulsing NFC symbol + arrows) during the card step. Left/right are in the app's + * LOGICAL landscape space, so RotatedRoot maps them to top/bottom in a portrait mount. */ + val nfcSide: String = "off", /** Large-donation alternative: at/above this many MINOR units the kiosk suggests a cheaper way * to give (bank transfer / Zelle QR) before the card. 0 disables it. */ val largeAmountThresholdMinor: Long = 0, diff --git a/android/app/src/main/java/org/openmasjidos/kiosk/net/KioskApi.kt b/android/app/src/main/java/org/openmasjidos/kiosk/net/KioskApi.kt index 017837e..fb86fd2 100644 --- a/android/app/src/main/java/org/openmasjidos/kiosk/net/KioskApi.kt +++ b/android/app/src/main/java/org/openmasjidos/kiosk/net/KioskApi.kt @@ -243,6 +243,7 @@ class KioskApi(private val client: OkHttpClient) { footerText = cfg.optString("footerText", "OpenMasjid Solutions"), tabSize = cfg.optString("tabSize", "medium"), orientation = cfg.optString("orientation", "0"), + nfcSide = cfg.optString("nfcSide", "off"), largeAmountThresholdMinor = cfg.optLong("largeAmountThresholdMinor", 0L), largeAmountNote = cfg.optString("largeAmountNote", ""), largeAmountImage = cfg.optString("largeAmountImage", ""), diff --git a/android/app/src/main/java/org/openmasjidos/kiosk/ui/GivingHome.kt b/android/app/src/main/java/org/openmasjidos/kiosk/ui/GivingHome.kt index e1e51b0..bb5d84e 100644 --- a/android/app/src/main/java/org/openmasjidos/kiosk/ui/GivingHome.kt +++ b/android/app/src/main/java/org/openmasjidos/kiosk/ui/GivingHome.kt @@ -171,6 +171,15 @@ fun GivingHome(vm: KioskViewModel, ui: UiState, modifier: Modifier = Modifier) { } } } + // Reader hint: a pulsing NFC symbol + arrows pinned to the reader's side (set per tablet in + // Admin → Devices). Shown while the donor is at the card step; turns green when it clears. It + // rides inside RotatedRoot with everything else, so "left/right" follow the mount into portrait. + NfcReaderHint( + side = cfg?.nfcSide ?: "off", + active = ui.giving.step == GivingStep.Card || ui.giving.step == GivingStep.Processing, + cleared = ui.giving.step == GivingStep.Thanks, + accent = accent, + ) // Visual-only countdown ring (no numbers/words): shown while a non-main tab idles OR while a // donation is under way (returns to the menu on inactivity). (ui.autoReturnStartedMs ?: ui.idleReturnStartedMs)?.let { started -> diff --git a/android/app/src/main/java/org/openmasjidos/kiosk/ui/NfcHint.kt b/android/app/src/main/java/org/openmasjidos/kiosk/ui/NfcHint.kt new file mode 100644 index 0000000..4476d94 --- /dev/null +++ b/android/app/src/main/java/org/openmasjidos/kiosk/ui/NfcHint.kt @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 OpenMasjid-Solutions + +package org.openmasjidos.kiosk.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import org.openmasjidos.kiosk.ui.theme.SuccessDark +import kotlin.math.PI +import kotlin.math.sin + +/** + * A calm, wordless hint that points a donor to the physical card reader while they're paying: a + * pulsing contactless (NFC) symbol pinned to the reader's side of the screen, with arrows marching + * toward it. When the payment clears the symbol turns green and the arrows fade away. + * + * `side` is the reader's position in the app's LOGICAL landscape space ("left" / "right" / "off"). + * The whole kiosk UI is drawn inside [RotatedRoot], so on a portrait mount left/right naturally become + * top/bottom — the admin sets it once per tablet and it follows the mount. + * + * Called from within a full-screen Box (see GivingHome); it aligns itself to the chosen edge. + */ +@Composable +fun BoxScope.NfcReaderHint( + side: String, + active: Boolean, + cleared: Boolean, + accent: Color, + modifier: Modifier = Modifier, +) { + val onLeft = side == "left" + val show = (onLeft || side == "right") && (active || cleared) + if (!show) return + + val color = if (cleared) SuccessDark else accent + Row( + modifier = modifier + .align(if (onLeft) Alignment.CenterStart else Alignment.CenterEnd) + .padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + // Symbol hugs the edge; arrows sit inboard of it and point back toward it. + if (onLeft) { + NfcSymbol(color = color, faceInboardLeft = true, pulsing = !cleared) + MarchingArrows(pointLeft = true, color = color, active = active && !cleared) + } else { + MarchingArrows(pointLeft = false, color = color, active = active && !cleared) + NfcSymbol(color = color, faceInboardLeft = false, pulsing = !cleared) + } + } +} + +/** The universal contactless mark — three nested arcs radiating from a dot near the reader edge, so it + * reads as waves coming off the reader toward the donor. Pulses gently while waiting; steady green + * once cleared. [faceInboardLeft] = reader on the LEFT, so the waves open to the right (inboard). */ +@Composable +private fun NfcSymbol(color: Color, faceInboardLeft: Boolean, pulsing: Boolean) { + val t = rememberInfiniteTransition(label = "nfc-pulse") + val pulse by t.animateFloat( + initialValue = 0.86f, + targetValue = 1.12f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "nfc-scale", + ) + val scale = if (pulsing) pulse else 1f + Canvas( + modifier = Modifier + .size(84.dp) + .graphicsLayer { scaleX = scale; scaleY = scale }, + ) { + val stroke = size.minDimension * 0.085f + val cy = size.height / 2f + // Source dot near the edge; arcs open toward the screen centre. + val cx = if (faceInboardLeft) size.width * 0.14f else size.width * 0.86f + val startAngle = if (faceInboardLeft) -55f else 125f // 0° = east; open right vs. open left + val sweep = 110f + for (i in 1..3) { + val r = size.minDimension * (0.16f + i * 0.13f) + drawArc( + color = color, + startAngle = startAngle, + sweepAngle = sweep, + useCenter = false, + topLeft = Offset(cx - r, cy - r), + size = Size(r * 2, r * 2), + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } + drawCircle(color = color, radius = stroke * 0.95f, center = Offset(cx, cy)) + } +} + +/** Three chevrons pointing at the symbol, with a brightness that travels toward it (marching-ants) so + * the eye is led to the reader. Fades out when [active] is false (payment done). */ +@Composable +private fun MarchingArrows(pointLeft: Boolean, color: Color, active: Boolean) { + val t = rememberInfiniteTransition(label = "nfc-arrows") + val phase by t.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1100, easing = LinearEasing)), + label = "nfc-phase", + ) + val groupAlpha by animateFloatAsState(if (active) 1f else 0f, tween(400), label = "nfc-arrows-fade") + Canvas( + modifier = Modifier + .size(width = 104.dp, height = 60.dp) + .graphicsLayer { alpha = groupAlpha }, + ) { + val n = 3 + val slotW = size.width / n + val halfW = slotW * 0.30f + val halfH = size.height * 0.30f + val stroke = size.height * 0.11f + val cy = size.height / 2f + for (k in 0 until n) { + // k ranks chevrons by distance from the symbol (0 = nearest). Place the nearest on the + // symbol's side, and make the bright spot sweep far → near as `phase` grows, so the motion + // reads as flowing toward the reader. + val slot = if (pointLeft) k else (n - 1 - k) + val cx = slotW * (slot + 0.5f) + val wave = 0.5f + 0.5f * sin(2f * PI.toFloat() * (phase - (n - 1 - k) / n.toFloat())) + val a = 0.28f + 0.72f * wave + drawChevron(cx, cy, halfW, halfH, pointLeft, color.copy(alpha = a), stroke) + } + } +} + +/** One "<" (pointLeft) or ">" chevron, apex on the side it points to. */ +private fun DrawScope.drawChevron( + cx: Float, + cy: Float, + halfW: Float, + halfH: Float, + pointLeft: Boolean, + color: Color, + stroke: Float, +) { + val apexX = if (pointLeft) cx - halfW else cx + halfW + val backX = if (pointLeft) cx + halfW else cx - halfW + val apex = Offset(apexX, cy) + drawLine(color, apex, Offset(backX, cy - halfH), strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(color, apex, Offset(backX, cy + halfH), strokeWidth = stroke, cap = StrokeCap.Round) +} diff --git a/server/src/index.ts b/server/src/index.ts index 49751bb..2d0bf91 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -607,16 +607,19 @@ async function main(): Promise { name: z.string().max(80).optional(), // A UI rotation in degrees ('0'/'90'/'180'/'270'); legacy named values are normalised in the store. orientation: z.string().max(20).optional(), + // Which side the reader sits on ('off'/'left'/'right'); normalised in the store. + nfcSide: z.string().max(20).optional(), }) .safeParse(req.body); - if (!parsed.success || (parsed.data.name === undefined && parsed.data.orientation === undefined)) { - return reply.code(400).send({ error: 'Please enter a name or orientation.' }); + if (!parsed.success || (parsed.data.name === undefined && parsed.data.orientation === undefined && parsed.data.nfcSide === undefined)) { + return reply.code(400).send({ error: 'Please enter a name, orientation, or reader side.' }); } const id = (req.params as { id: string }).id; let d = store.getDevice(id); if (!d) return reply.code(404).send({ error: 'Kiosk not found.' }); if (parsed.data.name !== undefined) d = store.renameDevice(id, parsed.data.name.trim()) ?? d; if (parsed.data.orientation !== undefined) d = store.setDeviceOrientation(id, parsed.data.orientation) ?? d; + if (parsed.data.nfcSide !== undefined) d = store.setDeviceNfcSide(id, parsed.data.nfcSide) ?? d; return { data: deviceView(d) }; }); diff --git a/server/src/store.test.ts b/server/src/store.test.ts index f5ea352..b44f5c6 100644 --- a/server/src/store.test.ts +++ b/server/src/store.test.ts @@ -132,6 +132,14 @@ test('per-device: campaign targeting filters getKioskConfig, and orientation is s.setDeviceOrientation(a.id, 'nonsense'); assert.equal(s.getDevice(a.id)!.orientation, '0'); // invalid → no rotation + // Reader side (NFC hint): per-device, defaults off, only left/right/off are kept, reaches the config. + assert.equal(s.getKioskConfig('', a.id).config.nfcSide, 'off'); // default + s.setDeviceNfcSide(a.id, 'left'); + assert.equal(s.getKioskConfig('', a.id).config.nfcSide, 'left'); + assert.equal(s.getKioskConfig('', b.id).config.nfcSide, 'off'); // per-device, B unaffected + s.setDeviceNfcSide(a.id, 'sideways'); // unknown → back to off + assert.equal(s.getDevice(a.id)!.nfcSide, 'off'); + // Revoking a device scrubs its id from every campaign's targeting, so a campaign aimed only at it // doesn't silently vanish fleet-wide — it falls back to "all kiosks" ([] = all). const both = s.createCampaign({ title: 'Both', deviceIds: [a.id, b.id] })!; diff --git a/server/src/store.ts b/server/src/store.ts index 142ff1f..b8f2fb9 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -340,6 +340,10 @@ export interface Device { * tablet rotates its own content by this angle (works even where the device ignores orientation * requests). Legacy named values are normalised to degrees on read. */ orientation: string; + /** Which side of the tablet the card reader sits on, so the kiosk can point donors to it during the + * card step: 'off' (no hint) | 'left' | 'right'. Left/right are in the app's LOGICAL landscape + * space, so RotatedRoot maps them to top/bottom when the device is rotated to portrait. */ + nfcSide: string; } /** Valid device orientations — a rotation applied to the kiosk UI in DEGREES ('0' = as mounted). We @@ -364,6 +368,17 @@ export function normalizeOrientation(v: unknown): DeviceOrientation { return LEGACY_ORIENTATION[s] ?? '0'; } +/** Valid NFC-reader sides — where the reader sits relative to the kiosk's LOGICAL landscape screen, + * so it can point donors to it. 'off' = show no hint (the default; existing kiosks are unchanged). */ +export const DEVICE_NFC_SIDES = ['off', 'left', 'right'] as const; +export type DeviceNfcSide = (typeof DEVICE_NFC_SIDES)[number]; + +/** Normalise any stored/incoming NFC side to a valid value (default 'off'). */ +export function normalizeNfcSide(v: unknown): DeviceNfcSide { + const s = String(v ?? ''); + return (DEVICE_NFC_SIDES as readonly string[]).includes(s) ? (s as DeviceNfcSide) : 'off'; +} + /** Short, URL-safe id with a kind prefix, e.g. "dev_a1b2c3d4". */ export function rid(prefix: string): string { return `${prefix}_${crypto.randomBytes(6).toString('hex')}`; @@ -400,7 +415,8 @@ export class Store { config_version INTEGER NOT NULL DEFAULT 0, identify INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, - orientation TEXT NOT NULL DEFAULT 'auto' + orientation TEXT NOT NULL DEFAULT 'auto', + nfc_side TEXT NOT NULL DEFAULT 'off' ); CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_token ON devices(token_hash); @@ -585,10 +601,11 @@ export class Store { // Per-campaign device targeting (which kiosks show this campaign; '[]' = all). if (!cols.includes('device_ids')) this.db.exec("ALTER TABLE campaigns ADD COLUMN device_ids TEXT NOT NULL DEFAULT '[]'"); } - // Per-device screen orientation (set from the web UI). + // Per-device screen orientation + NFC-reader side (both set from the web UI). { const dcols = (this.db.prepare('PRAGMA table_info(devices)').all() as { name: string }[]).map((c) => c.name); if (!dcols.includes('orientation')) this.db.exec("ALTER TABLE devices ADD COLUMN orientation TEXT NOT NULL DEFAULT 'auto'"); + if (!dcols.includes('nfc_side')) this.db.exec("ALTER TABLE devices ADD COLUMN nfc_side TEXT NOT NULL DEFAULT 'off'"); } // The per-child split of a tuition charge (students/billing v2) and the ticked bill lines (0.43.0). // Absent on installs that predate them; an in-flight outbox row without either simply lets Students @@ -1597,6 +1614,8 @@ export class Store { masjidName: this.getMasjid().name, // The UI rotation in degrees for THIS device (from the web UI); '0' = as mounted. orientation: (deviceId !== '' ? this.getDevice(deviceId)?.orientation : '') || '0', + // Which side the reader sits on for THIS device, so the card step can point donors to it. + nfcSide: (deviceId !== '' ? this.getDevice(deviceId)?.nfcSide : '') || 'off', // Global giving policy (per-campaign amounts/monthly/thank-you live on each campaign). manualEntryEnabled: g.manualEntryEnabled, namePolicy: g.namePolicy, @@ -1657,6 +1676,7 @@ export class Store { identify: !!r.identify, revoked: !!r.revoked, orientation: normalizeOrientation(r.orientation), + nfcSide: normalizeNfcSide(r.nfc_side), }; } @@ -1670,6 +1690,17 @@ export class Store { return this.getDevice(id); } + /** Set which side of the tablet the card reader sits on (from the web UI), so the kiosk can point + * donors to it during the card step. Bumps the config version. Returns the updated device (null if + * unknown). */ + setDeviceNfcSide(id: string, nfcSide: string): Device | null { + const s = normalizeNfcSide(nfcSide); + const res = this.db.prepare('UPDATE devices SET nfc_side = ? WHERE id = ?').run(s, id); + if (res.changes === 0) return null; + this.bumpConfigVersion(); // the tablet picks up the new NFC hint on its next heartbeat + return this.getDevice(id); + } + createDevice(input: { name: string; platform: string; tokenHash: string }): Device { const id = rid('dev'); this.db diff --git a/web/src/api.ts b/web/src/api.ts index af5b1f2..7ceff7e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -316,12 +316,19 @@ export interface Device { online: boolean; /** Forced screen orientation set from here (not the tablet's auto-rotate). */ orientation: DeviceOrientation; + /** Which side of the tablet the card reader sits on, so the kiosk can point donors to it during the + * card step. 'off' = no hint. */ + nfcSide: DeviceNfcSide; } /** Kiosk UI rotation in degrees ('0' = as mounted). The tablet rotates its own UI by this angle, so it * works even on tablets that ignore system orientation requests. */ export type DeviceOrientation = '0' | '90' | '180' | '270'; +/** Which side the card reader sits on, relative to the app's LOGICAL landscape screen. Left/right map + * to top/bottom when the tablet is rotated to portrait. 'off' shows no reader hint. */ +export type DeviceNfcSide = 'off' | 'left' | 'right'; + /** One structured log line from a kiosk (payments, reader events, errors). */ export interface DeviceLog { ts: string; @@ -363,6 +370,10 @@ export const renameDevice = (id: string, name: string) => export const setDeviceOrientation = (id: string, orientation: DeviceOrientation) => request(`/api/admin/devices/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ orientation }) }); +/** Set which side of the tablet the card reader sits on (delivered to the tablet on its next check-in). */ +export const setDeviceNfcSide = (id: string, nfcSide: DeviceNfcSide) => + request(`/api/admin/devices/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ nfcSide }) }); + export const revokeDevice = (id: string) => request<{ ok: true }>(`/api/admin/devices/${encodeURIComponent(id)}`, { method: 'DELETE' }); diff --git a/web/src/devices.tsx b/web/src/devices.tsx index d5a6e3e..ca2d416 100644 --- a/web/src/devices.tsx +++ b/web/src/devices.tsx @@ -36,8 +36,10 @@ import { renameDevice, revokeDevice, setDeviceOrientation, + setDeviceNfcSide, setRemoteAdoption, type DeviceOrientation, + type DeviceNfcSide, setKioskPin, type Campaign, type Device, @@ -470,6 +472,16 @@ function DeviceRow({ device, campaigns, serverVersion, onChange }: { device: Dev } }; + const changeNfcSide = async (nfcSide: DeviceNfcSide) => { + setErr(''); + try { + await setDeviceNfcSide(device.id, nfcSide); + onChange(); + } catch (e) { + setErr(errMsg(e)); + } + }; + const identify = async () => { setErr(''); setNote(''); @@ -553,6 +565,18 @@ function DeviceRow({ device, campaigns, serverVersion, onChange }: { device: Dev + {outOfDate && (