Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
167 changes: 167 additions & 0 deletions android/app/src/main/java/org/openmasjidos/kiosk/ui/NfcHint.kt
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 5 additions & 2 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,16 +607,19 @@ async function main(): Promise<void> {
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) };
});

Expand Down
8 changes: 8 additions & 0 deletions server/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] })!;
Expand Down
35 changes: 33 additions & 2 deletions server/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')}`;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1657,6 +1676,7 @@ export class Store {
identify: !!r.identify,
revoked: !!r.revoked,
orientation: normalizeOrientation(r.orientation),
nfcSide: normalizeNfcSide(r.nfc_side),
};
}

Expand All @@ -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
Expand Down
Loading
Loading