This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import React, { useMemo } from 'react';
import { View, type StyleProp, type ViewStyle } from 'react-native';
import Svg, { Path, Rect } from 'react-native-svg';
import QRCode from 'qrcode';
import { radiusMd } from '../theme/tokens';
export interface QrCanvasProps {
/** The exact string encoded in the QR (see `lib/qrPayload.buildQrPayload`). */
value: string;
/** Tile edge in points. DESIGN.md pins 240x240 on the QR display screen. */
size?: number;
style?: StyleProp<ViewStyle>;
/** Accessibility label override; defaults to a generic alarm-code label. */
accessibilityLabel?: string;
}
/**
* QR rendering with no native QR dependency: `qrcode` (pure JS) produces the
* module matrix, and `react-native-svg` draws it as one path on a light tile.
* The tile is always white with dark modules — a QR on a dark surface is not
* reliably scannable, so the tile is the one place in the app that is not
* token-dark (DESIGN.md §7.4).
*/
export function QrCanvas({
value,
size = 240,
style,
accessibilityLabel = 'Alert QR code',
}: QrCanvasProps) {
const path = useMemo(() => {
const qr = QRCode.create(value, { errorCorrectionLevel: 'M' });
const count: number = qr.modules.size;
const data = qr.modules.data as unknown as Uint8Array;
// 4 modules of quiet zone on each side, per the QR spec — the quiet zone is
// part of the encoded geometry so the drawn tile stays exactly `size`.
const quiet = 4;
const total = count + quiet * 2;
const unit = size / total;
const u = unit.toFixed(2);
let d = '';
for (let y = 0; y < count; y += 1) {
for (let x = 0; x < count; x += 1) {
if (!data[y * count + x]) continue;
const px = ((x + quiet) * unit).toFixed(2);
const py = ((y + quiet) * unit).toFixed(2);
d += `M${px} ${py}h${u}v${u}h-${u}z`;
}
}
return d;
}, [value, size]);
return (
<View
accessible
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
style={[
{ width: size, height: size, backgroundColor: '#FFFFFF', borderRadius: radiusMd },
style,
]}
>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Rect x={0} y={0} width={size} height={size} fill="#FFFFFF" />
<Path d={path} fill="#0B0D10" />
</Svg>
</View>
);
}
+279
View File
@@ -0,0 +1,279 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Animated,
Easing,
Modal,
Pressable,
StyleSheet,
View,
type LayoutChangeEvent,
} from 'react-native';
import { OctagonX, Zap } from 'lucide-react-native';
import { AppText, Button } from './ui';
import {
minTapTarget,
radiusLg,
radiusXl,
space2,
space4,
space6,
stateAlarm,
stateAlarmDeep,
stateAlarmInk,
surface,
text,
textSecondary,
typeBodyStrong,
typeCaption,
typeLabel,
typeMicro,
typeBody,
typeTitle,
triggerHoldMs,
} from '../theme/tokens';
/**
* Best-effort `impactHeavy`. The spec fires it exactly once per trigger attempt
* at hold completion; if the haptics module is unavailable the interaction
* still completes (haptics are never load-bearing).
*/
function impactHeavy(): void {
try {
// Lazy require so a missing native module cannot break the screen.
const Haptics = require('react-native-haptic-feedback');
Haptics.trigger('impactHeavy', { enableVibrateFallback: true, ignoreAndroidSystemSettings: false });
} catch {
// no-op
}
}
export interface TriggerPadProps {
/** Fired after the 1200ms hold completes and the user confirms. */
onTrigger: () => void;
/** Disarm is a single tap, no confirm, never rate-limited. */
onDisarm: () => void;
/**
* Whether the alarm is currently ringing. There is **no** arm/enablement gate:
* the service accepts a trigger from `idle` and `disarmed` too (no
* `armAlert` action exists), so the pad is always enabled and merely shows
* what the last event was.
*/
ringing?: boolean;
disabled?: boolean;
/** Why the pad is disabled — always rendered, so the reason is never a mystery. */
disabledReason?: string;
/** Number of devices the confirmation sheet will name. */
deviceCount?: number;
/** Confirmation copy; kept here so both screens agree verbatim. */
confirmTitle?: string;
}
/**
* Two-step trigger control (DESIGN.md §7.3):
* 1. press-and-hold 1200ms — a `stateAlarm` fill sweeps left→right across the
* pad's real width and the surface scales to 0.98; releasing early cancels
* with no side effect;
* 2. on completion, `impactHeavy` fires once and a **modal confirmation
* sheet** asks `Trigger the alarm on N devices?`, with the destructive
* action stacked and dominant.
*
* Disarm is deliberately NOT symmetric: one tap, no confirm, never disabled.
* Both controls are always present in one stack — the trigger is only visually
* disabled while the alarm is already ringing, so the pattern never moves under
* the user's thumb.
*/
export function TriggerPad({
onTrigger,
onDisarm,
ringing = false,
disabled,
disabledReason,
deviceCount = 0,
confirmTitle,
}: TriggerPadProps) {
const [confirming, setConfirming] = useState(false);
const [width, setWidth] = useState(0);
const fill = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
const animRef = useRef<Animated.CompositeAnimation | null>(null);
const cancelHold = useCallback(() => {
animRef.current?.stop();
animRef.current = null;
Animated.timing(fill, { toValue: 0, duration: 140, useNativeDriver: true }).start();
Animated.timing(scale, { toValue: 1, duration: 140, useNativeDriver: true }).start();
}, [fill, scale]);
useEffect(() => cancelHold, [cancelHold]);
const startHold = useCallback(() => {
if (disabled || confirming) return;
fill.setValue(0);
const anim = Animated.parallel([
Animated.timing(fill, {
toValue: 1,
duration: triggerHoldMs,
easing: Easing.linear,
useNativeDriver: true,
}),
Animated.timing(scale, {
toValue: 0.98,
duration: triggerHoldMs,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]);
animRef.current = anim;
anim.start(({ finished }) => {
animRef.current = null;
if (!finished) return;
impactHeavy();
setConfirming(true);
fill.setValue(0);
Animated.timing(scale, { toValue: 1, duration: 140, useNativeDriver: true }).start();
});
}, [disabled, confirming, fill, scale]);
// The sweep tracks the pad's actual width instead of a magic constant.
const translateX = fill.interpolate({
inputRange: [0, 1],
outputRange: [-Math.max(width, 1), 0],
});
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width || 0);
const triggerDisabled = disabled || ringing;
return (
<View style={styles.wrap}>
<Animated.View style={{ transform: [{ scale }] }}>
<Pressable
onLayout={onLayout}
onPressIn={startHold}
onPressOut={cancelHold}
disabled={triggerDisabled}
accessibilityRole="button"
accessibilityLabel="Hold to trigger the alarm. Press and hold for 1.2 seconds."
accessibilityHint="Releasing early cancels. You will be asked to confirm."
accessibilityState={{ disabled: triggerDisabled }}
style={[styles.pad, triggerDisabled ? styles.disabled : null]}
>
{/* Sweep: the fill is clipped by the pad's rounded corners. */}
<Animated.View style={[styles.sweep, { transform: [{ translateX }] }]} />
<View style={styles.padContent}>
<Zap size={24} color="#FFFFFF" />
<AppText style={styles.padLabel}>Hold to trigger</AppText>
</View>
</Pressable>
</Animated.View>
<AppText style={styles.hint}>
{ringing ? 'Alarm is ringing — disarm to stop it' : TRIGGER_HOLD_LABEL}
</AppText>
{triggerDisabled && (disabledReason || ringing) ? (
<AppText style={styles.reason}>{disabledReason ?? 'Already ringing on every device.'}</AppText>
) : null}
<Button
label="Disarm"
variant="secondary"
height={56}
onPress={onDisarm}
disabled={disabled}
icon={OctagonX}
accessibilityLabel="Disarm the alarm on every device"
/>
<AppText style={styles.hint}>Disarm stops the alarm on every device. One tap, no confirm.</AppText>
<Modal
visible={confirming}
transparent
animationType="fade"
onRequestClose={() => setConfirming(false)}
statusBarTranslucent
>
<View style={styles.scrim}>
<View style={styles.sheet}>
<AppText style={styles.sheetTitle}>
{confirmTitle ??
`Trigger the alarm on ${deviceCount} device${deviceCount === 1 ? '' : 's'}?`}
</AppText>
<AppText style={styles.sheetBody}>
Every subscribed device rings at maximum volume. Subscribers only hear it — they
cannot stop it.
</AppText>
<Button
label="Trigger now"
variant="alarm"
height={56}
onPress={() => {
setConfirming(false);
onTrigger();
}}
/>
<Button
label="Cancel"
variant="secondary"
height={48}
onPress={() => setConfirming(false)}
/>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
wrap: { gap: space6 },
pad: {
height: 64,
borderRadius: radiusLg,
// The alarm ramp, NOT `dangerDeep`: this control makes other people's phones
// scream, it does not delete anything (D-2).
backgroundColor: stateAlarmDeep,
overflow: 'hidden',
justifyContent: 'center',
minHeight: minTapTarget,
},
disabled: { opacity: 0.4 },
sweep: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '100%',
backgroundColor: stateAlarm,
},
padContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
},
padLabel: { ...typeBodyStrong, color: '#FFFFFF' },
hint: { ...typeCaption, color: textSecondary, textAlign: 'center' },
reason: { ...typeCaption, color: stateAlarmInk, textAlign: 'center', marginTop: -space4 },
scrim: {
flex: 1,
backgroundColor: 'rgba(11,13,16,0.72)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: surface,
borderTopLeftRadius: radiusXl,
borderTopRightRadius: radiusXl,
padding: space6,
gap: space4,
},
sheetTitle: { ...typeTitle, color: text },
sheetBody: { ...typeBody, color: textSecondary },
});
export const TRIGGER_HOLD_LABEL = `Hold for 1.2s — releases cancel`;
export const TRIGGER_DISABLED_HINT = 'Only the alert owner can trigger it.';
export const ALARM_INK = stateAlarmInk;
export const ALARM_STATE_COLOR = stateAlarm;
export const MONO_HINT = typeMicro;
export const PAD_LABEL_HINT = typeLabel;
+614
View File
@@ -0,0 +1,614 @@
import React, { useEffect, useRef } from 'react';
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
type TextInputProps,
} from 'react-native';
import { Text } from 'heroui-native/text';
import { Spinner } from 'heroui-native/spinner';
import { Surface } from 'heroui-native/surface';
import {
BellOff,
CircleAlert,
CircleDashed,
ShieldCheck,
Siren,
TriangleAlert,
WifiOff,
type LucideIcon,
} from 'lucide-react-native';
import {
border,
danger,
dangerDeep,
focusRing,
minTapTarget,
ok,
radiusFull,
radiusLg,
space1,
space2,
space3,
space4,
space6,
stateAlarmDeep,
surface,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBody,
typeBodyStrong,
typeCaption,
typeCode,
typeHeading,
typeLabel,
typeMicro,
typeTitle,
warn,
STATE_META,
type AlarmState,
} from '../theme/tokens';
// ── Primitives ───────────────────────────────────────────────────────────────
export function AppText({
children,
style,
className,
numberOfLines,
accessibilityLabel,
accessibilityLiveRegion,
}: {
children: React.ReactNode;
style?: object;
className?: string;
numberOfLines?: number;
accessibilityLabel?: string;
accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
}) {
return (
<Text
className={className}
style={style}
numberOfLines={numberOfLines}
accessibilityLabel={accessibilityLabel}
accessibilityLiveRegion={accessibilityLiveRegion}
>
{children}
</Text>
);
}
export function Screen({
children,
scroll = true,
}: {
children: React.ReactNode;
scroll?: boolean;
}) {
if (!scroll) {
return <View style={styles.flex}>{children}</View>;
}
return (
<ScrollView
style={styles.flex}
contentContainerStyle={styles.screenContent}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
);
}
export function Field({
label,
children,
accessibilityLabel,
}: {
label: string;
children: React.ReactNode;
accessibilityLabel?: string;
}) {
return (
<View style={styles.field}>
<AppText style={{ ...typeLabel, color: textMuted }}>{label}</AppText>
<View accessibilityLabel={accessibilityLabel}>{children}</View>
</View>
);
}
export function TextInputBox(props: TextInputProps) {
const [focused, setFocused] = React.useState(false);
const { style, onFocus, onBlur, ...rest } = props;
return (
<TextInput
placeholderTextColor={textFaint}
onFocus={e => {
setFocused(true);
onFocus?.(e);
}}
onBlur={e => {
setFocused(false);
onBlur?.(e);
}}
// The border token is decorative; a control boundary uses focusRing
// (DESIGN.md §2.1).
style={[styles.input, { borderColor: focused ? focusRing : border }, style]}
{...rest}
/>
);
}
export function Card({
children,
onPress,
style,
accessibilityLabel,
}: {
children: React.ReactNode;
onPress?: () => void;
style?: object;
accessibilityLabel?: string;
}) {
const inner = (
<Surface style={[styles.card, style]}>
<View accessibilityLabel={accessibilityLabel}>{children}</View>
</Surface>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
>
{inner}
</Pressable>
);
}
return inner;
}
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'ghost'
| 'alarm'
| 'danger'
| 'warn'
| 'outlineWhite'
| 'white';
export interface ButtonProps {
label: string;
onPress?: () => void;
variant?: ButtonVariant;
height?: number;
disabled?: boolean;
icon?: LucideIcon;
accessibilityLabel?: string;
style?: object;
}
const BUTTON_VARIANTS: Record<
ButtonVariant,
{ fill: string; fg: string; border: string; borderWidth: number }
> = {
primary: { fill: focusRing, fg: '#06121F', border: 'transparent', borderWidth: 0 },
secondary: { fill: surfaceRaised, fg: text, border: border, borderWidth: 1 },
ghost: { fill: 'transparent', fg: text, border: 'transparent', borderWidth: 0 },
alarm: { fill: stateAlarmDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 },
danger: { fill: dangerDeep, fg: '#FFFFFF', border: 'transparent', borderWidth: 0 },
warn: { fill: warn, fg: '#2A1A02', border: 'transparent', borderWidth: 0 },
outlineWhite: { fill: 'transparent', fg: '#FFFFFF', border: '#FFFFFF', borderWidth: 2 },
white: { fill: '#FFFFFF', fg: '#0B0D10', border: 'transparent', borderWidth: 0 },
};
export function Button({
label,
onPress,
variant = 'primary',
height = 56,
disabled,
icon: Icon,
accessibilityLabel,
style,
}: ButtonProps) {
const palette = BUTTON_VARIANTS[variant];
return (
<Pressable
onPress={disabled ? undefined : onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
accessibilityState={{ disabled: !!disabled }}
style={[
styles.button,
{
height,
backgroundColor: palette.fill,
borderColor: palette.border,
borderWidth: palette.borderWidth,
},
// Disabled is opacity .4 — never a colour-only change (DESIGN.md).
disabled ? styles.disabled : null,
style,
]}
>
{Icon ? <Icon size={20} color={palette.fg} /> : null}
<AppText style={{ ...typeBodyStrong, color: palette.fg }}>{label}</AppText>
</Pressable>
);
}
export function EmptyState({
icon: Icon,
title,
subtitle,
action,
}: {
icon?: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<View style={styles.empty}>
{Icon ? <Icon size={36} color={textFaint} /> : null}
<AppText style={{ ...typeHeading, color: text, marginTop: space4 }}>{title}</AppText>
{subtitle ? (
<AppText
style={{ ...typeCaption, color: textSecondary, marginTop: space2, textAlign: 'center' }}
>
{subtitle}
</AppText>
) : null}
{action ? <View style={styles.emptyAction}>{action}</View> : null}
</View>
);
}
export function LoadingView({ label = 'Loading…' }: { label?: string }) {
return (
<View style={styles.loading}>
<Spinner />
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space3 }}>{label}</AppText>
</View>
);
}
export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<View style={styles.errorBanner} accessibilityLiveRegion="polite">
<View style={styles.errorRow}>
<CircleAlert size={18} color={danger} />
<AppText style={{ ...typeLabel, color: danger, flex: 1 }}>{message}</AppText>
</View>
{onRetry ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Try again"
style={styles.retry}
>
<AppText style={{ ...typeLabel, color: danger, textDecorationLine: 'underline' }}>
Try again
</AppText>
</Pressable>
) : null}
</View>
);
}
/** Persistent offline strip (Cross-screen rules) — never an ErrorBanner. */
export function OfflineStrip() {
return (
<View style={styles.offlineStrip}>
<WifiOff size={16} color={warn} />
<AppText style={{ ...typeCaption, color: warn, flex: 1 }}>
Offline — actions will be sent when you reconnect
</AppText>
</View>
);
}
export function Header({
title,
subtitle,
onBack,
right,
}: {
title: string;
subtitle?: string;
onBack?: () => void;
right?: React.ReactNode;
}) {
return (
<View style={styles.header}>
{onBack ? (
<Pressable
onPress={onBack}
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.headerBack}
>
<AppText style={{ ...typeBodyStrong, color: textMuted }}>‹</AppText>
</Pressable>
) : null}
<View style={styles.flex}>
<AppText style={{ ...typeTitle, color: text }} numberOfLines={1}>
{title}
</AppText>
{subtitle ? (
<AppText style={{ ...typeCaption, color: textSecondary }} numberOfLines={1}>
{subtitle}
</AppText>
) : null}
</View>
{right}
</View>
);
}
// ── State components ─────────────────────────────────────────────────────────
const STATE_ICONS: Record<AlarmState, LucideIcon> = {
idle: CircleDashed,
armed: ShieldCheck,
triggered: Siren,
disarmed: BellOff,
};
export function stateIcon(state: AlarmState): LucideIcon {
return STATE_ICONS[state];
}
export function StatePill({ state, style }: { state: AlarmState; style?: object }) {
const meta = STATE_META[state];
const Icon = STATE_ICONS[state];
const filled = meta.chipFill !== null;
return (
<View
style={[
styles.pill,
{
backgroundColor: meta.chipFill ?? 'transparent',
borderColor: meta.color,
borderWidth: filled ? 0 : 1,
},
style,
]}
accessibilityLabel={meta.label}
>
<Icon size={14} color={filled ? meta.chipText : meta.color} />
<AppText style={{ ...typeMicro, color: filled ? meta.chipText : meta.color }}>
{meta.label}
</AppText>
</View>
);
}
/** Breathe (armed, 1800ms) / pulse (triggered, 700ms) / static otherwise. */
export function useStateMotion(state: AlarmState, reducedMotion = false) {
const anim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (reducedMotion || (state !== 'armed' && state !== 'triggered')) {
anim.setValue(1);
return;
}
const duration = state === 'armed' ? 1800 : 700;
const loop = Animated.loop(
Animated.sequence([
Animated.timing(anim, {
toValue: state === 'armed' ? 1.04 : 1.06,
duration: duration / 2,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(anim, {
toValue: 1,
duration: duration / 2,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]),
);
loop.start();
return () => loop.stop();
}, [state, reducedMotion, anim]);
return anim;
}
/**
* The state ring: width from the state meta, dashed for idle, plus corner ticks
* for triggered. Shape + icon + label carry the state, so colour is never the
* only signal.
*/
export function StateRing({
state,
size = 160,
reducedMotion = false,
}: {
state: AlarmState;
size?: number;
reducedMotion?: boolean;
}) {
const meta = STATE_META[state];
const Icon = STATE_ICONS[state];
const scale = useStateMotion(state, reducedMotion);
return (
<Animated.View style={{ transform: [{ scale }], width: size, height: size }}>
<View
style={[
styles.ring,
{
width: size,
height: size,
borderColor: meta.color,
borderWidth: meta.ringWidth,
borderStyle: meta.dashed ? 'dashed' : 'solid',
},
]}
>
<Icon size={48} color={meta.color} />
</View>
{state === 'triggered' ? (
<>
<View style={[styles.tickTopLeft, { borderTopColor: meta.color }]} />
<View style={[styles.tickTopRight, { borderTopColor: meta.color }]} />
<View style={[styles.tickBottomLeft, { borderBottomColor: meta.color }]} />
<View style={[styles.tickBottomRight, { borderBottomColor: meta.color }]} />
</>
) : null}
</Animated.View>
);
}
export function IconLine({
icon: Icon,
color,
children,
}: {
icon: LucideIcon;
color: string;
children: React.ReactNode;
}) {
return (
<View style={styles.iconLine}>
<Icon size={16} color={color} />
<AppText style={{ ...typeCaption, color, flex: 1 }}>{children}</AppText>
</View>
);
}
/** Trailing readiness icon: circle-check (ok) / triangle-alert / circle-alert. */
export function HealthIcon({ status }: { status: 'ok' | 'warn' | 'danger' }) {
if (status === 'ok') {
return (
<View style={styles.checkCircle}>
<AppText style={{ ...typeMicro, color: '#06240F' }}>✓</AppText>
</View>
);
}
if (status === 'warn') return <TriangleAlert size={20} color={warn} />;
return <CircleAlert size={20} color={danger} />;
}
export function Divider() {
return <View style={styles.divider} />;
}
export const styles = StyleSheet.create({
flex: { flex: 1 },
screenContent: { padding: space4, paddingBottom: 40 },
field: { gap: space2 },
input: {
borderRadius: radiusLg,
borderWidth: 1,
backgroundColor: surfaceRaised,
paddingHorizontal: space4,
paddingVertical: space3,
color: text,
...typeBody,
},
card: { backgroundColor: surface, borderRadius: radiusLg, padding: space4, elevation: 4 },
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
borderRadius: radiusLg,
paddingHorizontal: space4,
minHeight: minTapTarget,
},
disabled: { opacity: 0.4 },
empty: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: space6,
paddingVertical: 48,
},
emptyAction: { marginTop: space6, alignSelf: 'stretch' },
loading: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48 },
errorBanner: {
borderWidth: 1,
borderColor: 'rgba(248,113,113,0.3)',
backgroundColor: 'rgba(248,113,113,0.1)',
borderRadius: radiusLg,
padding: space4,
marginBottom: space4,
},
errorRow: { flexDirection: 'row', alignItems: 'center', gap: space2 },
retry: { marginTop: space2, minHeight: 32, justifyContent: 'center' },
offlineStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
backgroundColor: 'rgba(251,191,36,0.12)',
paddingHorizontal: space4,
paddingVertical: space2,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
paddingHorizontal: space4,
paddingVertical: space3,
borderBottomWidth: 1,
borderBottomColor: border,
},
headerBack: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: space1,
borderRadius: radiusFull,
paddingHorizontal: space3,
paddingVertical: space1,
alignSelf: 'flex-start',
},
ring: { alignItems: 'center', justifyContent: 'center', borderRadius: radiusFull },
tickTopLeft: { position: 'absolute', top: 0, left: 0, width: 18, height: 18, borderTopWidth: 4 },
tickTopRight: { position: 'absolute', top: 0, right: 0, width: 18, height: 18, borderTopWidth: 4 },
tickBottomLeft: {
position: 'absolute',
bottom: 0,
left: 0,
width: 18,
height: 18,
borderBottomWidth: 4,
},
tickBottomRight: {
position: 'absolute',
bottom: 0,
right: 0,
width: 18,
height: 18,
borderBottomWidth: 4,
},
iconLine: { flexDirection: 'row', alignItems: 'center', gap: space2 },
checkCircle: {
width: 20,
height: 20,
borderRadius: radiusFull,
backgroundColor: ok,
alignItems: 'center',
justifyContent: 'center',
},
divider: { height: 1, backgroundColor: border, marginVertical: space3 },
mono: { ...typeCode, color: text },
body: { ...typeBody, color: text },
caption: { ...typeCaption, color: textSecondary },
});
+177
View File
@@ -0,0 +1,177 @@
/**
* QR payload contract for the Alarm client.
*
* Canonical payload (frozen contract):
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
*
* Also accepted:
* - the same URL with a trailing slash / query noise,
* - a bare custom scheme: `dcntrlized-alarm://a/{alertId}#{token}`,
* - a relative `a/{alertId}#{token}`,
* - raw `{alertId}#{token}` (what the owner reads off the QR panel),
* - a pasted token alone when the caller already knows the alertId
* (manual fallback) — see `parseManualToken`.
*
* This module is intentionally pure (no React, no RN imports) so it is testable
* under jest without a native environment, and so the same parser is used by
* the scanner, the deep-link handler and the manual paste field.
*/
export const QR_HOST = 'alarm.dcntrlized.org';
export const QR_SCHEME = 'dcntrlized-alarm';
export const QR_PATH_PREFIX = '/a/';
export interface QrPayload {
alertId: string;
subscribeToken: string;
/** Where the payload came from — drives copy in the scanner error states. */
source: 'url' | 'scheme' | 'relative' | 'raw';
}
export type QrParseError =
| 'empty'
| 'not-an-alarm-code'
| 'missing-alert-id'
| 'missing-token';
export type QrParseResult =
| { ok: true; payload: QrPayload }
| { ok: false; error: QrParseError };
const OK = (payload: QrPayload): QrParseResult => ({ ok: true, payload });
const FAIL = (error: QrParseError): QrParseResult => ({ ok: false, error });
/** Canonical payload an owner renders into a QR code. */
export function buildQrPayload(alertId: string, subscribeToken: string): string {
return `https://${QR_HOST}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`;
}
/** Custom-scheme form, used by deep links and by the "share" affordance. */
export function buildQrSchemePayload(alertId: string, subscribeToken: string): string {
return `${QR_SCHEME}://a/${encodeURIComponent(alertId)}#${subscribeToken}`;
}
function splitFragment(value: string): [string, string] {
const i = value.indexOf('#');
return i === -1 ? [value, ''] : [value.slice(0, i), value.slice(i + 1)];
}
function stripQuery(value: string): string {
const i = value.indexOf('?');
return i === -1 ? value : value.slice(0, i);
}
/**
* Extract the alert id from a path. `/a/{id}` is the contract path; the id is
* whatever follows the `a/` prefix, so a trailing slash yields "" (missing)
* rather than the literal "a".
*/
function alertIdFromPath(path: string): string {
const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, '');
if (!trimmed) return '';
const segments = trimmed.split('/').filter(Boolean);
if (segments[0] === 'a') {
return segments.length > 1 ? segments[1] : '';
}
return segments[segments.length - 1] ?? '';
}
/**
* Parse a scanned / pasted QR payload. Never throws.
*/
export function parseQrPayload(raw: string): QrParseResult {
const trimmed = (raw ?? '').trim();
if (!trimmed) return FAIL('empty');
const [head, fragment] = splitFragment(trimmed);
const withoutQuery = stripQuery(head);
const token = decodeURIComponent(fragment.trim());
const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(withoutQuery);
if (schemeMatch) {
const scheme = schemeMatch[1].toLowerCase();
if (scheme !== QR_SCHEME && scheme !== 'http' && scheme !== 'https') {
return FAIL('not-an-alarm-code');
}
const rest = withoutQuery.slice(schemeMatch[0].length);
// For http(s) the first segment is the authority (`host[:port]`); for the
// custom scheme `rest` is already a path.
let path: string;
if (scheme === 'http' || scheme === 'https') {
const slash = rest.indexOf('/');
const authority = (slash === -1 ? rest : rest.slice(0, slash)).split(':')[0].toLowerCase();
const hostOk =
authority === QR_HOST ||
authority.endsWith(`.${QR_HOST}`) ||
authority === 'localhost' ||
authority === '127.0.0.1' ||
/^\d{1,3}(\.\d{1,3}){3}$/.test(authority);
if (!hostOk) return FAIL('not-an-alarm-code');
path = slash === -1 ? '' : rest.slice(slash);
} else {
path = rest.startsWith('/') ? rest : `/${rest}`;
}
const seg = alertIdFromPath(path);
if (!seg) return FAIL('missing-alert-id');
if (!token) return FAIL('missing-token');
return OK({
alertId: decodeURIComponent(seg),
subscribeToken: token,
source: scheme === QR_SCHEME ? 'scheme' : 'url',
});
}
// No scheme: accept `a/{id}#{token}` or `{id}#{token}`.
const cleaned = withoutQuery.replace(/^\/+/, '');
const seg = alertIdFromPath(cleaned);
if (!seg) return FAIL('missing-alert-id');
if (!token) return FAIL('missing-token');
const looksRelative = cleaned.startsWith('a/') || cleaned.startsWith(`${QR_SCHEME}/`);
return OK({
alertId: decodeURIComponent(seg),
subscribeToken: token,
source: looksRelative ? 'relative' : 'raw',
});
}
/**
* Manual fallback: the user pastes either a full payload or just the token
* fragment, while the alertId is already known (they are on the alert's join
* screen or typed the id in).
*/
export function parseManualToken(input: string, alertId: string): QrParseResult {
const trimmed = (input ?? '').trim();
if (!trimmed) return FAIL('empty');
const withId = parseQrPayload(trimmed);
if (withId.ok) {
return withId;
}
if (!alertId.trim()) {
return withId.error === 'missing-token' ? FAIL('missing-alert-id') : withId;
}
// A bare token: no scheme, no '#', no '/'.
if (/^[A-Za-z0-9._~-]+$/.test(trimmed)) {
return OK({ alertId: alertId.trim(), subscribeToken: trimmed, source: 'raw' });
}
return withId;
}
/** Human copy for a parse failure (scanner error states, DESIGN.md §7.5). */
export function qrErrorMessage(error: QrParseError): string {
switch (error) {
case 'empty':
return 'Nothing was scanned';
case 'not-an-alarm-code':
return 'Not a Dcntrlized alert code';
case 'missing-alert-id':
return 'This code has no alert id';
case 'missing-token':
return 'This code has no subscribe token';
}
}
/** Tokens render truncated to 12 chars + ellipsis (DESIGN.md §7.4). */
export function truncateToken(token: string, max = 12): string {
if (token.length <= max) return token;
return `${token.slice(0, max)}…`;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Human time helpers for the alarm surfaces.
*
* The takeover shows a count-up ("2s ago") rather than a server timestamp: on a
* lock screen at arm's length an RFC3339 string is unreadable, and the number
* that matters is how long the phone has been screaming. The absolute time is
* still available as the accessibility label.
*/
/** Parse a server RFC3339 string. Returns null when absent or unparseable. */
export function parseServerTime(value: string | null | undefined): Date | null {
if (!value) return null;
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : new Date(ms);
}
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/**
* Compact elapsed time. Deliberately coarse above a minute: the takeover updates
* every second only while the alarm is fresh, and "13m ago" is more useful than
* "13m 42s ago" on a wall of red.
*/
export function relativeTime(from: Date, now: number): string {
const elapsed = now - from.getTime();
// A clock skew between phone and server must not render "in 3 seconds".
if (elapsed < SECOND) return 'just now';
if (elapsed < MINUTE) return `${Math.floor(elapsed / SECOND)}s ago`;
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
return `${Math.floor(elapsed / DAY)}d ago`;
}
/** Convenience: relative time for a server string, or null when unparseable. */
export function relativeSince(value: string | null | undefined, now: number): string | null {
const date = parseServerTime(value);
return date ? relativeTime(date, now) : null;
}
/** Local wall-clock time for the accessibility label of a relative stamp. */
export function absoluteLocal(date: Date): string {
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
+13
View File
@@ -0,0 +1,13 @@
export type RootStackParamList = {
Login: undefined;
Home: undefined;
/** Admin control surface (viewer holds the admin token). */
AlertDetail: { alertId: string; role?: 'admin' | 'subscriber' };
QrDisplay: { alertId: string };
Scanner: { alertId?: string } | undefined;
Settings: undefined;
/** Live alarm takeover — presented as a modal outside the tab shell. */
LiveAlarm: { alertId: string; eventId?: string; message?: string; serverTime?: string };
};
export type HomeTab = 'admin' | 'subscribed';
+37
View File
@@ -0,0 +1,37 @@
import { createNavigationContainerRef } from '@react-navigation/native';
import type { RootStackParamList } from './navigation';
/**
* A navigation ref so non-React code (the alarm socket callbacks, the native
* foreground service's intent handling) can raise the takeover without being
* inside a screen. `Root` in App.tsx keeps the auth-driven resets; this ref is
* only used for the alarm takeover, which must work from any screen.
*/
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
export function openLiveAlarm(params: RootStackParamList['LiveAlarm']): void {
if (!navigationRef.isReady()) return;
const current = navigationRef.getCurrentRoute()?.name;
if (current === 'LiveAlarm') {
// Re-fire on an already-open takeover: replace so the newest event/message
// is what is rendered.
navigationRef.dispatch(state => ({
...state,
routes: state.routes.map(route =>
route.name === 'LiveAlarm' ? { ...route, params } : route,
),
}));
return;
}
navigationRef.navigate('LiveAlarm', params);
}
export function closeLiveAlarm(): void {
if (!navigationRef.isReady()) return;
if (navigationRef.getCurrentRoute()?.name !== 'LiveAlarm') return;
if (navigationRef.canGoBack()) {
navigationRef.goBack();
} else {
navigationRef.navigate('Home');
}
}
+627
View File
@@ -0,0 +1,627 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
AlertTriangle,
BellOff,
Copy,
QrCode,
RefreshCw,
ScanLine,
ShieldCheck,
Smartphone,
Trash2,
Wifi,
WifiOff,
} from 'lucide-react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { useAlarm, useSubscriptions } from '../state/AlarmContext';
import { useConnection } from '../state/ConnectionContext';
import * as api from '../services/alarmApi';
import { formatAutoStop } from '../services/alarmApi';
import { relativeSince } from '../lib/time';
import { truncateToken } from '../lib/qrPayload';
import { TriggerPad } from '../components/TriggerPad';
import {
AppText,
Button,
Card,
Divider,
EmptyState,
ErrorBanner,
Field,
Header,
IconLine,
LoadingView,
OfflineStrip,
StatePill,
StateRing,
TextInputBox,
} from '../components/ui';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
danger,
focusRing,
minTapTarget,
radiusLg,
radiusMd,
space1,
space2,
space3,
space4,
space6,
stateArmed,
stateDisarmed,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeCode,
typeHeading,
typeMicro,
warn,
type AlarmState,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
const NEW = 'new';
export default function AlertDetailScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const alertId = params?.alertId ?? NEW;
if (alertId === NEW) return <CreateAlertView onCreated={id => navigation.replace('AlertDetail', { alertId: id, role: 'admin' })} />;
return <AlertDetailView alertId={alertId} role={params?.role} />;
}
// ── Create ───────────────────────────────────────────────────────────────────
function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }) {
const navigation = useNavigation<Nav>();
const { createAlert } = useAlarm();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [autoStop, setAutoStop] = useState('60');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
setError(null);
try {
const seconds = Number.parseInt(autoStop, 10);
const created = await createAlert({
name: name.trim(),
message: message.trim() || undefined,
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
});
onCreated(created.alertId);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create the alert.');
} finally {
setBusy(false);
}
};
return (
<View style={styles.root}>
<Header title="New alert" subtitle="Create the alert, then show its code" onBack={() => navigation.goBack()} />
<ScrollView contentContainerStyle={styles.content}>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>What is this alert for?</AppText>
<Field label="Alert name">
<TextInputBox
value={name}
onChangeText={setName}
placeholder="Front door"
accessibilityLabel="Alert name"
/>
</Field>
<Field label="Message (optional)">
<TextInputBox
value={message}
onChangeText={setMessage}
placeholder="Come to the front door"
accessibilityLabel="Message"
/>
</Field>
<Field label="Auto-stop (seconds)">
<TextInputBox
value={autoStop}
onChangeText={setAutoStop}
keyboardType="number-pad"
placeholder="60"
accessibilityLabel="Auto-stop in seconds"
/>
</Field>
<AppText style={{ ...typeCaption, color: textSecondary }}>
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
after this, on every device.
</AppText>
</View>
</Card>
{error ? <ErrorBanner message={error} onRetry={submit} /> : null}
<Button
label={busy ? 'Creating…' : 'Create alert'}
icon={ShieldCheck}
onPress={submit}
disabled={busy || !name.trim()}
/>
<AppText style={styles.note}>
The owner token is shown once, right after creation. Store it before you leave the next
screen — it cannot be recovered.
</AppText>
</ScrollView>
</View>
);
}
// ── Detail ───────────────────────────────────────────────────────────────────
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
const navigation = useNavigation<Nav>();
const { online } = useConnection();
const {
adminAlerts,
getAlert,
adminTokenFor,
updateAlertLocal,
forgetAdminAlert,
leaveAlert,
liveEvent,
refresh,
setActiveAlertId,
delivery,
socketState,
} = useAlarm();
const alert = getAlert(alertId);
const isAdmin = role === 'admin' || !!adminAlerts.find(a => a.alertId === alertId);
const adminToken = adminTokenFor(alertId);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [revealToken, setRevealToken] = useState(false);
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
isAdmin ? alertId : null,
isAdmin,
);
// Read the authoritative state on mount; the local copy is only a cache.
const loadServerAlert = useCallback(async () => {
try {
const fresh = await api.getAlert(alertId);
setServerAlert(fresh);
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
} catch {
// Offline is fine — the cached copy renders.
}
}, [alertId, updateAlertLocal]);
useEffect(() => {
setActiveAlertId(isAdmin ? null : alertId);
loadServerAlert();
}, [alertId, isAdmin, loadServerAlert, setActiveAlertId]);
// `AlertStatus` IS the four-state ramp — no mapping needed.
const state: AlarmState = serverAlert?.status ?? alert?.status ?? 'idle';
// `armed`/`disarmed` are ringing-state history, not an enablement gate: there
// is no arm control and no `armAlert` action, and the service accepts a
// trigger from any state. So the copy labels the last event instead of
// promising a re-arm that does not exist.
const lastEventAt = serverAlert?.lastEvent?.serverTime ?? serverAlert?.triggeredAt ?? serverAlert?.disarmedAt;
const lastEventAgo = relativeSince(lastEventAt, Date.now());
const stateSentence =
state === 'triggered'
? 'Ringing now on every subscribed device.'
: state === 'disarmed'
? lastEventAgo
? `Disarmed ${lastEventAgo} — it will ring again the moment the owner triggers it.`
: 'Disarmed — it will ring again the moment the owner triggers it.'
: state === 'armed'
? lastEventAgo
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
: 'Armed — triggering rings every subscribed device.'
: 'Idle — triggering rings every subscribed device.';
const live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
const effectiveState = live ? 'triggered' : state;
const deviceCount = subs.length;
const doTrigger = async () => {
if (!adminToken) return;
setBusy(true);
setError(null);
try {
const res = await api.triggerAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'triggered', lastTriggered: res.serverTime });
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
} finally {
setBusy(false);
}
};
const doDisarm = async () => {
if (!adminToken) return;
setBusy(true);
setError(null);
try {
await api.disarmAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'disarmed' });
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
setNotice('Disarmed on every device.');
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
} finally {
setBusy(false);
}
};
const doDelete = async () => {
if (!adminToken) return;
setBusy(true);
setError(null);
try {
await api.deleteAlert({ alertId, adminToken });
await forgetAdminAlert(alertId);
navigation.goBack();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
} finally {
setBusy(false);
}
};
const doLeave = async () => {
setBusy(true);
setError(null);
try {
await leaveAlert(alertId);
navigation.goBack();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not leave this alert.');
} finally {
setBusy(false);
}
};
const copy = async (value: string, label: string) => {
try {
Clipboard.setString(value);
setNotice(`${label} copied.`);
} catch {
setNotice('Could not copy — long-press the value to select it.');
}
};
if (!alert && !serverAlert) {
return (
<View style={styles.root}>
<Header title="Alert" onBack={() => navigation.goBack()} />
<EmptyState
icon={AlertTriangle}
title="Alert not found on this device"
subtitle="This device has no token for that alert. Scan its QR code again to rejoin."
action={<Button label="Scan a QR code" icon={ScanLine} onPress={() => navigation.navigate('Scanner')} />}
/>
</View>
);
}
return (
<View style={styles.root}>
{!online ? <OfflineStrip /> : null}
<Header
title={alert?.name ?? serverAlert?.name ?? alertId}
subtitle={isAdmin ? 'Owner controls' : 'Subscribed device'}
onBack={() => navigation.goBack()}
right={
<Pressable
onPress={refresh}
accessibilityRole="button"
accessibilityLabel="Refresh status"
style={styles.iconButton}
>
<RefreshCw size={18} color={textMuted} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{error ? <ErrorBanner message={error} onRetry={loadServerAlert} /> : null}
{notice ? (
<View style={styles.notice} accessibilityLiveRegion="polite">
<AppText style={{ ...typeCaption, color: stateDisarmed }}>{notice}</AppText>
</View>
) : null}
<View style={styles.ringWrap}>
<StateRing state={effectiveState} />
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
<AppText style={styles.stateLine}>{stateSentence}</AppText>
</View>
{isAdmin ? (
<AdminControls
adminToken={adminToken}
revealToken={revealToken}
onRevealToken={() => setRevealToken(true)}
onCopy={copy}
deviceCount={deviceCount}
busy={busy}
ringing={effectiveState === 'triggered'}
onTrigger={doTrigger}
onDisarm={doDisarm}
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
/>
) : (
<SubscriberControls
alertId={alertId}
alert={alert}
delivery={delivery}
socketState={socketState}
busy={busy}
onLeave={doLeave}
/>
)}
{isAdmin ? (
<View style={{ gap: space3 }}>
<View style={styles.sectionHead}>
<AppText style={{ ...typeHeading, color: text }}>Subscribed devices</AppText>
<AppText style={{ ...typeMicro, color: textFaint }}>{deviceCount}</AppText>
</View>
{subsError ? <ErrorBanner message={subsError} onRetry={reloadSubs} /> : null}
{subsLoading && !subs.length ? (
<LoadingView label="Loading devices…" />
) : subs.length === 0 ? (
<Card>
<IconLine icon={Smartphone} color={textMuted}>
No device has scanned this alert yet. Show the QR code to subscribe one.
</IconLine>
</Card>
) : (
subs.map(sub => (
<Card key={sub.subscriptionId}>
<View style={styles.subRow}>
<View style={{ flex: 1, gap: space1 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>
{sub.deviceLabel || sub.deviceId}
</AppText>
<AppText style={{ ...typeMicro, color: textFaint }}>
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
</AppText>
</View>
<Pressable
onPress={async () => {
try {
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
reloadSubs();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
}
}}
accessibilityRole="button"
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
style={styles.iconButton}
>
<Trash2 size={18} color={danger} />
</Pressable>
</View>
</Card>
))
)}
</View>
) : null}
<Divider />
{/* Destructive ramp is separate from the alarm ramp (DESIGN.md §2.3). */}
{isAdmin ? (
<View style={{ gap: space2 }}>
<Button
label="Delete alert"
variant="danger"
icon={Trash2}
disabled={busy}
onPress={doDelete}
/>
<AppText style={styles.note}>
Deleting removes the alert and its tokens. Every subscribed device stops receiving it.
</AppText>
</View>
) : (
<View style={{ gap: space2 }}>
<Button label="Leave this alert" variant="danger" icon={BellOff} disabled={busy} onPress={doLeave} />
<AppText style={styles.note}>
Leaving removes this device from the alert. You can rejoin with the QR code.
</AppText>
</View>
)}
</ScrollView>
</View>
);
}
function AdminControls({
adminToken,
revealToken,
onRevealToken,
onCopy,
deviceCount,
busy,
ringing,
onTrigger,
onDisarm,
onShowQr,
autoStopSeconds,
}: {
adminToken: string | null;
revealToken: boolean;
onRevealToken: () => void;
onCopy: (value: string, label: string) => void;
deviceCount: number;
busy: boolean;
/** Does the alarm ring on other devices right now (drives the pad's copy). */
ringing: boolean;
onTrigger: () => void;
onDisarm: () => void;
onShowQr: () => void;
autoStopSeconds?: number;
}) {
return (
<View style={{ gap: space4 }}>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Owner token</AppText>
{adminToken ? (
<>
<Pressable
onPress={() => (revealToken ? onCopy(adminToken, 'Owner token') : onRevealToken())}
accessibilityRole="button"
accessibilityLabel={revealToken ? 'Copy owner token' : 'Reveal owner token'}
style={styles.tokenBox}
>
<AppText style={{ ...typeCode, color: text }}>
{revealToken ? truncateToken(adminToken, 24) : truncateToken(adminToken)}
</AppText>
<Copy size={16} color={textFaint} />
</Pressable>
<AppText style={{ ...typeMicro, color: textFaint }}>
{revealToken ? 'Tap to copy' : 'Tap to reveal the first 24 characters'}
</AppText>
</>
) : (
<IconLine icon={AlertTriangle} color={warn}>
No owner token is stored on this device, so this alert cannot be triggered from here.
</IconLine>
)}
</View>
</Card>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Control</AppText>
<TriggerPad
ringing={ringing}
onTrigger={onTrigger}
onDisarm={onDisarm}
disabled={busy || !adminToken}
disabledReason={
!adminToken
? 'This device has no owner token, so it cannot trigger the alarm.'
: busy
? 'Working…'
: undefined
}
deviceCount={deviceCount}
/>
{autoStopSeconds ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
</AppText>
) : null}
</View>
</Card>
<Card onPress={onShowQr} accessibilityLabel="Show the QR code to subscribe a device">
<View style={styles.qrRow}>
<QrCode size={22} color={focusRing} />
<View style={{ flex: 1 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>Show QR code</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
Subscribes a device in one scan
</AppText>
</View>
</View>
</Card>
</View>
);
}
function SubscriberControls({
alert,
delivery,
socketState,
busy,
onLeave,
}: {
alertId: string;
alert: api.Alert | undefined;
delivery: string;
socketState: string;
busy: boolean;
onLeave: () => void;
}) {
return (
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
{delivery === 'socket'
? `Connected for live delivery (${socketState}).`
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
</IconLine>
<IconLine icon={ShieldCheck} color={stateArmed}>
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
</IconLine>
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
</View>
</Card>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
content: { padding: space4, paddingBottom: 48, gap: space4 },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
notice: {
borderWidth: 1,
borderColor: 'rgba(95,224,200,0.3)',
backgroundColor: 'rgba(95,224,200,0.1)',
borderRadius: radiusLg,
padding: space3,
},
ringWrap: { alignItems: 'center', paddingVertical: space6 },
stateLine: { ...typeCaption, color: textSecondary, textAlign: 'center', marginTop: space3 },
sectionHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' },
subRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
tokenBox: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: space3,
backgroundColor: surfaceRaised,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
paddingHorizontal: space4,
minHeight: minTapTarget,
},
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
note: { ...typeCaption, color: textSecondary },
});
+354
View File
@@ -0,0 +1,354 @@
import React, { useState } from 'react';
import { Alert as RNAlert, Pressable, RefreshControl, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
BellRing,
ChevronRight,
Plus,
QrCode,
ScanLine,
Settings as SettingsIcon,
ShieldCheck,
Siren,
} from 'lucide-react-native';
import { useAlarm } from '../state/AlarmContext';
import { useAuth } from '../state/AuthContext';
import { useConnection } from '../state/ConnectionContext';
import {
AppText,
Button,
Card,
EmptyState,
ErrorBanner,
LoadingView,
OfflineStrip,
StatePill,
} from '../components/ui';
import type { RootStackParamList } from '../navigation';
import type { Alert } from '../services/alarmApi';
import {
bg,
border,
focusRing,
minTapTarget,
radiusFull,
radiusLg,
space2,
space3,
space4,
space6,
space10,
stateAlarm,
surface,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeLabel,
typeMicro,
typeTitle,
type AlarmState,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Tab = 'admin' | 'subscribed';
export default function HomeScreen() {
const navigation = useNavigation<Nav>();
const { username, logout } = useAuth();
const { online } = useConnection();
const {
adminAlerts,
subscribedAlerts,
loading,
error,
reload,
liveEvent,
delivery,
socketState,
} = useAlarm();
const [tab, setTab] = useState<Tab>('admin');
const [refreshing, setRefreshing] = useState(false);
const onRefresh = async () => {
setRefreshing(true);
await reload();
setRefreshing(false);
};
const list = tab === 'admin' ? adminAlerts : subscribedAlerts;
return (
<View style={styles.root}>
{!online ? <OfflineStrip /> : null}
<View style={styles.header}>
<View style={{ flex: 1 }}>
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
{username ? `Signed in as ${username}` : 'Max-volume alerts for your devices'}
</AppText>
</View>
<Pressable
onPress={() => navigation.navigate('Settings')}
accessibilityRole="button"
accessibilityLabel="Settings and health"
style={styles.iconButton}
>
<SettingsIcon size={22} color={textMuted} />
</Pressable>
</View>
{/* Live banner: only while an alarm is actually up. */}
{liveEvent?.action === 'trigger' ? (
<Pressable
onPress={() => navigation.navigate('LiveAlarm', { alertId: liveEvent.alertId })}
accessibilityRole="button"
accessibilityLabel="An alarm is live. Open the takeover."
style={styles.liveBanner}
>
<Siren size={20} color="#FFFFFF" />
<AppText style={{ ...typeLabel, color: '#FFFFFF', flex: 1 }}>
Alarm live on {liveEvent.alertId}
</AppText>
<ChevronRight size={18} color="#FFFFFF" />
</Pressable>
) : null}
<View style={styles.tabs}>
<TabButton
label="My alerts"
icon={ShieldCheck}
active={tab === 'admin'}
count={adminAlerts.length}
onPress={() => setTab('admin')}
/>
<TabButton
label="Subscribed"
icon={BellRing}
active={tab === 'subscribed'}
count={subscribedAlerts.length}
onPress={() => setTab('subscribed')}
/>
</View>
<ScrollView
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{error ? <ErrorBanner message={error} onRetry={reload} /> : null}
{loading && !list.length ? (
<LoadingView label="Loading your alerts…" />
) : list.length === 0 ? (
tab === 'admin' ? (
<EmptyState
icon={ShieldCheck}
title="No alerts yet"
subtitle="Create an alert, show its QR code, and every device that scans it will ring when you trigger it."
action={
<Button
label="Create an alert"
icon={Plus}
onPress={() => navigation.navigate('AlertDetail', { alertId: 'new' })}
/>
}
/>
) : (
<EmptyState
icon={QrCode}
title="Not subscribed to anything"
subtitle="Scan the QR code the alert owner shows you, or paste the code by hand."
action={
<Button
label="Scan a QR code"
icon={ScanLine}
onPress={() => navigation.navigate('Scanner')}
/>
}
/>
)
) : (
<View style={{ gap: space3 }}>
{list.map(alert => (
<AlertRow
key={alert.alertId}
alert={alert}
live={liveEvent?.alertId === alert.alertId && liveEvent.action === 'trigger'}
onPress={() =>
navigation.navigate('AlertDetail', { alertId: alert.alertId, role: alert.role })
}
/>
))}
{tab === 'subscribed' ? (
<AppText style={styles.deliveryNote}>
{delivery === 'socket'
? `Live delivery connected (${socketState}).`
: 'Live delivery unavailable in proxy mode — status is polled every 5s.'}
</AppText>
) : null}
</View>
)}
<View style={styles.footerActions}>
{tab === 'admin' ? (
<Button
label="New alert"
icon={Plus}
variant="secondary"
onPress={() => navigation.navigate('AlertDetail', { alertId: 'new' })}
/>
) : null}
<Button
label="Scan a QR code"
icon={ScanLine}
variant="secondary"
onPress={() => navigation.navigate('Scanner')}
/>
<Button
label="Sign out"
variant="ghost"
height={44}
onPress={() =>
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign out', style: 'destructive', onPress: logout },
])
}
/>
</View>
</ScrollView>
</View>
);
}
function TabButton({
label,
icon: Icon,
active,
count,
onPress,
}: {
label: string;
icon: typeof ShieldCheck;
active: boolean;
count: number;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
accessibilityLabel={`${label}, ${count} alert${count === 1 ? '' : 's'}`}
style={[styles.tab, active ? styles.tabActive : null]}
>
<Icon size={16} color={active ? focusRing : textMuted} />
<AppText style={{ ...typeLabel, color: active ? focusRing : textMuted }}>{label}</AppText>
{count > 0 ? (
<View style={[styles.badge, active ? styles.badgeActive : null]}>
<AppText style={{ ...typeMicro, color: active ? '#06121F' : textMuted }}>{count}</AppText>
</View>
) : null}
</Pressable>
);
}
function AlertRow({ alert, live, onPress }: { alert: Alert; live: boolean; onPress: () => void }) {
const state: AlarmState = live ? 'triggered' : alert.status;
const Icon = alert.role === 'admin' ? ShieldCheck : BellRing;
return (
<Card onPress={onPress} accessibilityLabel={`${alert.name}. Open alert detail.`}>
<View style={styles.row}>
<View style={[styles.rowIcon, live ? { borderColor: stateAlarm } : null]}>
<Icon size={20} color={live ? stateAlarm : textMuted} />
</View>
<View style={{ flex: 1, gap: space2 }}>
<AppText style={{ ...typeBodyStrong, color: text }} numberOfLines={1}>
{alert.name || alert.alertId}
</AppText>
<View style={styles.rowMeta}>
<StatePill state={state} />
<AppText style={{ ...typeMicro, color: textFaint }}>
{alert.role === 'admin' ? 'Owner' : 'Subscribed'}
</AppText>
</View>
</View>
<ChevronRight size={20} color={textFaint} />
</View>
</Card>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
paddingHorizontal: space4,
paddingTop: space4,
paddingBottom: space3,
},
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
liveBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
backgroundColor: stateAlarm,
marginHorizontal: space4,
marginBottom: space3,
paddingHorizontal: space4,
paddingVertical: space3,
borderRadius: radiusLg,
},
tabs: { flexDirection: 'row', gap: space2, paddingHorizontal: space4, paddingBottom: space3 },
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
minHeight: minTapTarget,
borderRadius: radiusLg,
backgroundColor: surfaceRaised,
borderWidth: 1,
borderColor: border,
},
tabActive: { borderColor: focusRing },
badge: {
minWidth: 20,
height: 20,
paddingHorizontal: 6,
borderRadius: radiusFull,
backgroundColor: surface,
alignItems: 'center',
justifyContent: 'center',
},
badgeActive: { backgroundColor: focusRing },
content: { paddingHorizontal: space4, paddingBottom: space10 },
row: { flexDirection: 'row', alignItems: 'center', gap: space3 },
rowIcon: {
width: 40,
height: 40,
borderRadius: radiusFull,
borderWidth: 2,
borderColor: border,
alignItems: 'center',
justifyContent: 'center',
},
rowMeta: { flexDirection: 'row', alignItems: 'center', gap: space2 },
deliveryNote: { ...typeCaption, color: textSecondary, paddingTop: space2 },
footerActions: { gap: space2, paddingTop: space6 },
});
+397
View File
@@ -0,0 +1,397 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
Animated,
BackHandler,
Easing,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BellOff, OctagonX, Siren, VolumeX } from 'lucide-react-native';
import { useAlarm } from '../state/AlarmContext';
import { useAuth } from '../state/AuthContext';
import { AppText } from '../components/ui';
import * as api from '../services/alarmApi';
import { openSoundSettings, playAlarm, stopAlarm, isAlarmNativeAvailable } from '../services/alarmNative';
import { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
import type { RootStackParamList } from '../navigation';
import {
motionSiren,
radiusFull,
space2,
space3,
space4,
space6,
takeoverFlat,
onAlarm,
onAlarmDim,
typeBodyStrong,
typeCaption,
typeDisplay,
typeHeading,
typeLabel,
typeMicro,
warn,
warnInk,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'LiveAlarm'>;
const ACK_LABEL = 'Acknowledge';
const STOP_LABEL = 'Stop for everyone';
/**
* Live alarm takeover (DESIGN.md §8, `01-alarm-ux.md` §2, specs §9).
*
* Deliberately dependency-free at render time: everything shown comes from the
* event already in hand, never from a fetch, because the contract's target is
* under a second on a LAN. There is no offline strip and no error state that
* could hide the siren — the sound is bundled and local.
*
* Visual weight is intentional and audited (D-1): **Acknowledge** is the solid
* white primary because it is the safe, reversible action ("silence this
* device"); **Stop for everyone** is an outline because it is irreversible and
* affects other people. On a screaming phone the thumb finds the biggest white
* target first, and that must never be the one that ends the alarm everywhere.
*/
export default function LiveAlarmScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const { liveEvent, clearLiveEvent, adminAlerts, adminTokenFor, subscribedAlerts, updateAlertLocal } =
useAlarm();
const { username } = useAuth();
const alertId = params.alertId;
const event = liveEvent && liveEvent.alertId === alertId ? liveEvent : null;
const message = event?.message ?? params.message;
const alert =
adminAlerts.find(a => a.alertId === alertId) ?? subscribedAlerts.find(a => a.alertId === alertId);
const isOwner = !!adminAlerts.find(a => a.alertId === alertId);
const adminToken = adminTokenFor(alertId);
const [acknowledged, setAcknowledged] = useState(false);
const [volumeBlocked, setVolumeBlocked] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reduceMotion, setReduceMotion] = useState(false);
const [now, setNow] = useState(() => Date.now());
const shake = useRef(new Animated.Value(0)).current;
const fade = useRef(new Animated.Value(0)).current;
const serverTime = event?.serverTime ?? params.serverTime;
const triggeredAt = parseServerTime(serverTime);
/**
* Attribution. The alarm is always attributed to *a human*, never to "the
* system" — but the wire has no actor field (`alertView` in
* `Alarm/src/actions/alerts.go:131` and `realtime.AlarmFrame` carry none, and
* this service only ever holds the admin's bare UUID), so a subscriber cannot
* know the handle. Lead ruling 2026-10-18: when the handle is unavailable the
* line still attributes the alarm, just without a name —
* `The alert owner triggered the alarm`. The `@{handle}` form is used when the
* data genuinely exists (the owner's own view, or the day the service sends
* `owner`/`ownerUsername`), and is read opportunistically rather than assumed.
*/
const ownerHandle =
(alert as (api.Alert & { owner?: string; ownerUsername?: string }) | undefined)?.owner ||
(alert as (api.Alert & { ownerUsername?: string }) | undefined)?.ownerUsername ||
(isOwner ? username : null) ||
null;
const attribution = ownerHandle
? `@${ownerHandle} triggered the alarm`
: 'The alert owner triggered the alarm';
// Reduced motion: a 900ms stepped klaxon shake is exactly what this setting
// exists to suppress. Subscribe so a mid-alarm preference change is honoured.
useEffect(() => {
let mounted = true;
AccessibilityInfo.isReduceMotionEnabled()
.then(enabled => mounted && setReduceMotion(enabled))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
return () => {
mounted = false;
sub.remove();
};
}, []);
// The count-up ticks every second while the takeover is on screen.
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
Animated.timing(fade, { toValue: 1, duration: 220, useNativeDriver: true }).start();
if (reduceMotion) return;
// A stepped ±8° oscillation reads as a physical klaxon; a smooth full
// rotation reads as a loading spinner (spec §2 item 1).
const loop = Animated.loop(
Animated.timing(shake, {
toValue: 1,
duration: motionSiren,
easing: Easing.linear,
useNativeDriver: true,
}),
);
loop.start();
return () => {
loop.stop();
shake.setValue(0);
};
}, [fade, shake, reduceMotion]);
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
// refuses the alarm stream, the notification fallback carries it and the
// screen says so instead of pretending.
useEffect(() => {
let cancelled = false;
(async () => {
const res = await playAlarm(alertId, message ?? '', event?.autoStopSeconds ?? 0);
if (cancelled) return;
if (!res.ok && res.reason !== 'native-module-unavailable') setVolumeBlocked(true);
if (res.ok && res.volumeRaised === false) setVolumeBlocked(true);
})();
return () => {
cancelled = true;
stopAlarm();
};
}, [alertId, message, event?.autoStopSeconds]);
const leave = useCallback(() => {
clearLiveEvent();
if (navigation.canGoBack()) navigation.goBack();
else navigation.navigate('Home');
}, [clearLiveEvent, navigation]);
/**
* Acknowledge silences **this device only**. It must not leave the screen:
* the acknowledged state is the confirmation, the route to Stop has to stay
* reachable, and the alarm may still be live elsewhere (D-10).
*/
const ack = useCallback(async () => {
setAcknowledged(true);
try {
await stopAlarm();
} catch {
// Best effort: the notification fallback still applies.
}
// Once the native player is stopped this device is silent, so the live
// event no longer needs to drive a takeover — but the screen stays mounted
// in its acknowledged state until the user leaves it.
clearLiveEvent();
}, [clearLiveEvent]);
const stop = useCallback(async () => {
if (!adminToken) {
setError('Only the alert owner can stop the alarm on every device.');
return;
}
setBusy(true);
setError(null);
try {
await api.disarmAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'disarmed' });
await stopAlarm();
leave();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not stop the alarm.');
} finally {
setBusy(false);
}
}, [adminToken, alertId, leave, updateAlertLocal]);
// Hardware back and the back gesture are consumed: the only exits are
// Acknowledge and Stop alarm (spec, Cross-screen rules).
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, []);
// `steps(1, end)` as a square wave: a linear driver sampled at the halfway
// point jumps −8° → +8° with no interpolation, which is the mechanical klaxon
// jerk the spec asks for (a smooth oscillation reads as decoration).
const rotate = shake.interpolate({
inputRange: [0, 0.499, 0.5, 1],
outputRange: ['-8deg', '-8deg', '8deg', '8deg'],
});
const elapsed = relativeSince(serverTime, now);
const elapsedLabel = elapsed ? `on your device · ${elapsed}` : 'on your device';
const absoluteLabel = triggeredAt ? `Triggered at ${absoluteLocal(triggeredAt)}` : undefined;
return (
<Animated.View style={[styles.root, { opacity: fade }]}>
<View style={styles.center}>
<Animated.View style={reduceMotion ? undefined : { transform: [{ rotate }] }}>
<Siren size={48} color={onAlarm} />
</Animated.View>
<AppText style={styles.label}>ALARM</AppText>
{/* The alert name is the headline: at the moment of maximum stress this
is the one piece of information the user needs (D-5). */}
<AppText style={styles.name} numberOfLines={2}>
{alert?.name ?? alertId}
</AppText>
<AppText style={styles.owner}>{attribution}</AppText>
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
{elapsedLabel}
</AppText>
{message ? <AppText style={styles.message}>{message}</AppText> : null}
{acknowledged ? (
<AppText style={styles.ackNote} accessibilityLiveRegion="polite">
Acknowledged — this device is silent. The alarm may still be live elsewhere.
</AppText>
) : null}
</View>
<View style={styles.actions}>
{volumeBlocked ? (
<View style={styles.volumeStrip}>
<VolumeX size={16} color={warn} />
<View style={styles.volumeCopy}>
<AppText style={styles.volumeText}>
Volume is locked by your device — turn it up now
</AppText>
</View>
<Pressable
onPress={() => {
openSoundSettings();
}}
accessibilityRole="button"
accessibilityLabel="Open the system volume controls"
style={styles.raiseButton}
>
<AppText style={styles.raiseLabel}>Raise volume</AppText>
</Pressable>
</View>
) : null}
{!isAlarmNativeAvailable ? (
<AppText style={styles.note}>
No native alarm engine in this build — playing in-app only.
</AppText>
) : null}
{error ? (
<View style={styles.errorBox}>
<AppText style={{ ...typeLabel, color: onAlarm }}>{error}</AppText>
</View>
) : null}
{/* D-1: Acknowledge is the solid primary (safe, reversible). */}
<Pressable
onPress={ack}
disabled={acknowledged || busy}
accessibilityRole="button"
accessibilityLabel="Acknowledge the alarm on this device"
accessibilityHint="Silences this device only. The alarm keeps ringing on others."
style={[styles.ack, acknowledged || busy ? styles.disabled : null]}
>
<BellOff size={20} color={takeoverFlat} />
<AppText style={styles.ackLabel}>{acknowledged ? 'Acknowledged' : ACK_LABEL}</AppText>
</Pressable>
{/* Stop for everyone is owner-only and deliberately quieter than ACK. */}
{isOwner ? (
<Pressable
onPress={stop}
disabled={busy}
accessibilityRole="button"
accessibilityLabel="Stop the alarm on every device"
accessibilityHint="Ends the alarm for every subscribed device."
style={[styles.stop, busy ? styles.disabled : null]}
>
<OctagonX size={20} color={onAlarm} />
<AppText style={styles.stopLabel}>{busy ? 'Stopping…' : STOP_LABEL}</AppText>
</Pressable>
) : null}
<AppText style={styles.footer}>
{isOwner
? 'Acknowledge silences this device only · Stop ends it for everyone'
: 'Acknowledge silences this device · only the alert owner can stop it for everyone'}
</AppText>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
// Gradients must degrade to a flat fill; this is the documented flat value.
backgroundColor: takeoverFlat,
paddingHorizontal: space6,
paddingTop: space6,
paddingBottom: space6 + space2,
justifyContent: 'space-between',
},
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space3 },
label: { ...typeMicro, color: onAlarmDim, letterSpacing: 1.6 },
name: { ...typeDisplay, color: onAlarm, textAlign: 'center' },
owner: { ...typeHeading, color: onAlarm, textAlign: 'center' },
elapsed: { ...typeLabel, color: onAlarmDim },
message: { ...typeBodyStrong, color: onAlarm, textAlign: 'center' },
ackNote: { ...typeCaption, color: onAlarm, textAlign: 'center' },
actions: { gap: space3 },
volumeStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: space3,
borderWidth: 1,
borderColor: warn,
borderRadius: radiusFull,
paddingLeft: space4,
paddingRight: space2,
paddingVertical: space2,
minHeight: 44,
},
volumeCopy: { flex: 1 },
volumeText: { ...typeLabel, color: warn },
raiseButton: {
height: 36,
paddingHorizontal: space4,
borderRadius: radiusFull,
backgroundColor: warn,
alignItems: 'center',
justifyContent: 'center',
},
raiseLabel: { ...typeLabel, color: warnInk },
note: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
errorBox: {
borderWidth: 1,
borderColor: onAlarm,
borderRadius: radiusFull,
paddingHorizontal: space4,
paddingVertical: space3,
},
ack: {
height: 64,
borderRadius: radiusFull,
backgroundColor: onAlarm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
},
ackLabel: { ...typeBodyStrong, color: takeoverFlat },
stop: {
height: 56,
borderRadius: radiusFull,
borderWidth: 2,
borderColor: onAlarm,
backgroundColor: 'transparent',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
},
stopLabel: { ...typeBodyStrong, color: onAlarm },
disabled: { opacity: 0.4 },
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
});
+149
View File
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { useAuth } from '../state/AuthContext';
import { useConnection } from '../state/ConnectionContext';
import {
AppText,
Button,
ErrorBanner,
Field,
OfflineStrip,
Screen,
TextInputBox,
} from '../components/ui';
import { space4, text, textMuted, textSecondary, typeCaption, typeLabel, typeTitle } from '../theme/tokens';
type Mode = 'signin' | 'register';
export default function LoginScreen() {
const { login, register } = useAuth();
const { online, resolved } = useConnection();
const [mode, setMode] = useState<Mode>('signin');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isRegister = mode === 'register';
const canSubmit = !!username.trim() && !!password && (!isRegister || !!email.trim()) && online && !busy;
const submit = async () => {
if (!canSubmit) return;
setBusy(true);
setError(null);
try {
if (isRegister) {
await register(username.trim(), password, email.trim());
} else {
await login(username.trim(), password);
}
} catch (e) {
setError(
e instanceof Error && e.message
? e.message
: isRegister
? 'Could not create the account. Check your details and try again.'
: 'Sign in failed. Check your username and password.',
);
} finally {
setBusy(false);
}
};
return (
<View style={{ flex: 1 }}>
{!online ? <OfflineStrip /> : null}
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Screen>
<View style={{ gap: space4, paddingTop: space4 }}>
<View style={{ gap: space4, marginBottom: space4 }}>
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
Max-volume alerts for your devices
</AppText>
<AppText style={{ ...typeLabel, color: textMuted }}>
{isRegister ? 'Create account' : 'Sign in'}
</AppText>
</View>
<Field label="Username" accessibilityLabel="Username">
<TextInputBox
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="Username"
placeholder="your username"
/>
</Field>
<Field label="Password" accessibilityLabel="Password, hidden characters">
<TextInputBox
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
accessibilityLabel="Password, hidden characters"
placeholder="••••••••"
/>
</Field>
{isRegister ? (
<Field label="Email" accessibilityLabel="Email">
<TextInputBox
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
accessibilityLabel="Email"
placeholder="[email protected]"
/>
</Field>
) : null}
{error ? <ErrorBanner message={error} /> : null}
<Button
label={
busy
? isRegister
? 'Creating account…'
: 'Signing in…'
: isRegister
? 'Create account'
: 'Sign in'
}
onPress={submit}
disabled={!canSubmit}
height={56}
/>
<Button
label={isRegister ? 'Have an account? Sign in' : 'New here? Create an account'}
variant="ghost"
height={44}
onPress={() => {
setError(null);
setMode(isRegister ? 'signin' : 'register');
}}
/>
{!online ? (
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
{resolved
? 'The alarm service did not answer. Check Settings → Connection.'
: 'Looking for the alarm service…'}
</AppText>
) : null}
</View>
</Screen>
</KeyboardAvoidingView>
</View>
);
}
+288
View File
@@ -0,0 +1,288 @@
import React, { useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Copy, Info, RefreshCw, SunMedium } from 'lucide-react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { QrCanvas } from '../components/QrCanvas';
import { AppText, Button, Card, Header } from '../components/ui';
import { useAlarm } from '../state/AlarmContext';
import * as api from '../services/alarmApi';
import { buildQrPayload, truncateToken } from '../lib/qrPayload';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
radiusLg,
radiusMd,
radiusXl,
space2,
space3,
space4,
space6,
stateAlarm,
surface,
surfaceRaised,
text,
textFaint,
textSecondary,
typeBody,
typeCaption,
typeCode,
typeHeading,
typeLabel,
typeMicro,
typeTitle,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'QrDisplay'>;
/**
* QR display. The payload is built locally from the stored subscribe token so
* the screen works with no network; the server-rendered PNG is only a fallback
* for the (unlikely) case where the local render fails.
*
* Brightness is **not** raised: there is no brightness dependency in the tree,
* so the hint tells the user to raise it themselves. The spec's "brightness is
* raised" claim was removed rather than shipped as a false affordance (D-11).
*/
export default function QrDisplayScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const alertId = params.alertId;
const { getAlert, subscribeTokenFor, adminTokenFor, updateAlertLocal } = useAlarm();
const alert = getAlert(alertId);
const adminToken = adminTokenFor(alertId);
const [notice, setNotice] = useState<string | null>(null);
const [rotatedToken, setRotatedToken] = useState<string | null>(null);
const [confirmRotate, setConfirmRotate] = useState(false);
const [rotating, setRotating] = useState(false);
const [error, setError] = useState<string | null>(null);
const storedToken = subscribeTokenFor(alertId);
const token = rotatedToken ?? storedToken;
const payload = useMemo(
() => (token ? buildQrPayload(alertId, token) : null),
[alertId, token],
);
const copy = () => {
if (!payload) return;
try {
Clipboard.setString(payload);
setNotice('Alert link copied.');
} catch {
setNotice('Could not copy — long-press the code below to select it.');
}
};
/**
* Rotating invalidates the QR/join path only: people who already joined stay
* subscribed (verified in `Alarm/src/actions/alerts.go` rotateSubscribeToken).
* Two-step confirm, same shape as the trigger, because it silently makes every
* printed code useless.
*/
const rotate = async () => {
if (!adminToken) {
setError('Only the alert owner can rotate the subscribe token.');
setConfirmRotate(false);
return;
}
setRotating(true);
setError(null);
try {
const res = await api.rotateSubscribeToken({ alertId, adminToken });
setRotatedToken(res.subscribeToken);
updateAlertLocal(alertId, { subscribeToken: res.subscribeToken, qrPayload: res.qrPayload });
setNotice('Token rotated. Codes you shared before now no longer work.');
setConfirmRotate(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not rotate the token.');
} finally {
setRotating(false);
}
};
if (!token) {
return (
<View style={styles.root}>
<Header title="QR code" onBack={() => navigation.goBack()} />
<View style={styles.body}>
<AppText style={{ ...typeHeading, color: text }}>No subscribe token here</AppText>
<AppText style={styles.para}>
This device does not hold the subscribe token for {alertId}, so it cannot show a code
that would join anyone. Open the alert from the device that created it.
</AppText>
</View>
</View>
);
}
return (
<View style={styles.root}>
<Header
title={alert?.name ?? 'Alert code'}
subtitle="Scan to subscribe this device"
onBack={() => navigation.goBack()}
/>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.tileWrap}>
<QrCanvas
value={payload ?? ''}
size={240}
accessibilityLabel={`QR code that subscribes a device to ${alert?.name ?? alertId}`}
/>
</View>
<View style={styles.hintRow}>
<SunMedium size={16} color={textSecondary} />
<AppText style={{ ...typeCaption, color: textSecondary, flex: 1 }}>
Turn your screen brightness up — this app cannot do it for you
</AppText>
</View>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Or paste the code by hand</AppText>
<Pressable
onPress={copy}
accessibilityRole="button"
accessibilityLabel="Copy the alert link"
style={styles.codeBox}
>
<AppText style={{ ...typeCode, color: text, flex: 1 }} numberOfLines={2}>
{payload}
</AppText>
<Copy size={16} color={textFaint} />
</Pressable>
<AppText style={{ ...typeMicro, color: textFaint }}>
Subscribe token {truncateToken(token, 12)}
</AppText>
{notice ? (
<AppText style={{ ...typeCaption, color: focusRing }} accessibilityLiveRegion="polite">
{notice}
</AppText>
) : null}
{error ? (
<AppText style={{ ...typeCaption, color: stateAlarm }} accessibilityLiveRegion="polite">
{error}
</AppText>
) : null}
</View>
</Card>
{/* Rotation: revocability is one of the product's four promises. */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Rotate the token</AppText>
<AppText style={styles.para}>
Rotating makes every code you shared before stop working. Devices that already joined
stay subscribed — you would have to revoke those individually.
</AppText>
<Button
label={adminToken ? 'Rotate token' : 'Only the owner can rotate'}
variant="secondary"
icon={RefreshCw}
disabled={!adminToken || rotating}
onPress={() => setConfirmRotate(true)}
/>
</View>
</Card>
{/* Informational, not destructive: sharing a code is not a delete (D-24). */}
<View style={styles.infoBox}>
<View style={styles.infoRow}>
<Info size={18} color={textSecondary} />
<AppText style={{ ...typeLabel, color: text, flex: 1 }}>Keep this code private</AppText>
</View>
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space2 }}>
Anyone who scans it can subscribe a device to this alert. The owner token that controls
triggering is separate and is shown only once — it cannot be recovered.
</AppText>
</View>
<Button label="Done" variant="secondary" onPress={() => navigation.goBack()} />
</ScrollView>
<Modal
visible={confirmRotate}
transparent
animationType="fade"
onRequestClose={() => setConfirmRotate(false)}
statusBarTranslucent
>
<View style={styles.scrim}>
<View style={styles.sheet}>
<AppText style={{ ...typeTitle, color: text }}>Rotate the subscribe token?</AppText>
<AppText style={{ ...typeBody, color: textSecondary }}>
New devices can no longer join with the current QR. Devices already subscribed keep
working — remove them individually, or delete the alert to end it for everyone.
</AppText>
<Button
label={rotating ? 'Rotating…' : 'Rotate token'}
variant="alarm"
height={56}
disabled={rotating}
onPress={rotate}
/>
<Button
label="Cancel"
variant="secondary"
height={48}
onPress={() => setConfirmRotate(false)}
/>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
content: { padding: space4, paddingBottom: 48, gap: space4, alignItems: 'stretch' },
tileWrap: { alignItems: 'center', paddingVertical: space6 },
body: { padding: space4, gap: space3 },
para: { ...typeCaption, color: textSecondary },
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
paddingHorizontal: space2,
},
codeBox: {
flexDirection: 'row',
alignItems: 'center',
gap: space3,
backgroundColor: surfaceRaised,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
padding: space3,
minHeight: 48,
},
infoBox: {
borderWidth: 1,
borderColor: border,
backgroundColor: surface,
borderRadius: radiusLg,
padding: space4,
},
infoRow: { flexDirection: 'row', alignItems: 'center', gap: space2 },
scrim: {
flex: 1,
backgroundColor: 'rgba(11,13,16,0.72)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: surface,
borderTopLeftRadius: radiusXl,
borderTopRightRadius: radiusXl,
padding: space6,
gap: space4,
},
});
+369
View File
@@ -0,0 +1,369 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
Animated,
Easing,
Keyboard,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Camera,
useCameraDevice,
useCameraPermission,
useObjectOutput,
} from 'react-native-vision-camera';
import { ScanLine, X } from 'lucide-react-native';
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
import { useAlarm } from '../state/AlarmContext';
import { joinErrorMessage } from '../services/alarmApi';
import { parseQrPayload, parseManualToken, qrErrorMessage } from '../lib/qrPayload';
import { openAppSettings } from '../services/alarmNative';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
minTapTarget,
motionSiren,
radiusFull,
scrim,
stateIdle,
space2,
space3,
space4,
surface,
text,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeHeading,
typeLabel,
warn,
} from '../theme/tokens';
/** §7.5: the scan window is 280×280 with 3px corner brackets. */
const VIEWFINDER = 280;
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'Scanner'>;
/**
* QR scanner.
*
* The camera is `react-native-vision-camera` 5.x, which ships the object
* scanner in core (`useObjectOutput({types:['qr']})`) — there is no separate
* code-scanner plugin in v5. The manual paste field below is always available,
* including when the camera is denied or unavailable, because a subscribe token
* is a plain string.
*/
export default function ScannerScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const { joinAlert, deviceId } = useAlarm();
const { hasPermission, requestPermission, canRequestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manual, setManual] = useState('');
const [showManual, setShowManual] = useState(false);
const [joinedName, setJoinedName] = useState<string | null>(null);
const handled = useRef(false);
const [reduceMotion, setReduceMotion] = useState(false);
const sweepValue = useRef(new Animated.Value(0)).current;
useEffect(() => {
let mounted = true;
AccessibilityInfo.isReduceMotionEnabled()
.then(enabled => mounted && setReduceMotion(enabled))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
return () => {
mounted = false;
sub.remove();
};
}, []);
// The scan line is the affordance that says the scanner is working. Under
// reduce-motion it parks in the middle instead of sweeping.
useEffect(() => {
if (reduceMotion) {
sweepValue.setValue(0.5);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(sweepValue, {
toValue: 1,
duration: motionSiren,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sweepValue, {
toValue: 0,
duration: motionSiren,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]),
);
loop.start();
return () => loop.stop();
}, [reduceMotion, sweepValue]);
const targetAlertId = params?.alertId;
const complete = useCallback(
async (alertId: string, token: string) => {
if (busy || handled.current) return;
handled.current = true;
setBusy(true);
setError(null);
try {
const record = await joinAlert({
alertId,
subscribeToken: token,
deviceLabel: `Android ${deviceId?.slice(-4) ?? ''}`.trim(),
});
setJoinedName(record.name || alertId);
// Give the confirmation a beat, then return to the detail screen.
setTimeout(() => {
navigation.replace('AlertDetail', { alertId, role: 'subscriber' });
}, 700);
} catch (e) {
handled.current = false;
setError(joinErrorMessage(e));
} finally {
setBusy(false);
}
},
[busy, deviceId, joinAlert, navigation],
);
const onScanned = useCallback(
(objects: readonly { type: string; value?: string }[]) => {
for (const obj of objects) {
const value = typeof obj.value === 'string' ? obj.value : undefined;
if (obj.type !== 'qr' || !value) continue;
const parsed = parseQrPayload(value);
if (parsed.ok) {
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
setError('That code is for a different alert.');
continue;
}
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
} else {
setError(qrErrorMessage(parsed.error));
}
return;
}
},
[complete, targetAlertId],
);
// v5 ships the scanner in core: `useObjectOutput` is the code-scanner — there
// is no separate vision-camera-code-scanner plugin for 5.x.
const objectOutput = useObjectOutput({
types: ['qr'],
onObjectsScanned: onScanned,
});
const submitManual = async () => {
Keyboard.dismiss();
const trimmed = manual.trim();
if (!trimmed) return;
// Accept a full link, a scheme link, or a bare token when we know the alert.
const parsed = parseQrPayload(trimmed);
if (parsed.ok) {
await complete(parsed.payload.alertId, parsed.payload.subscribeToken);
return;
}
// A bare token can only be resolved when we arrived here from an alert.
if (targetAlertId) {
const byToken = parseManualToken(trimmed, targetAlertId);
if (byToken.ok) {
await complete(byToken.payload.alertId, byToken.payload.subscribeToken);
return;
}
}
setError(qrErrorMessage(parsed.error));
};
const sweep = sweepValue.interpolate({
inputRange: [0, 1],
outputRange: [-VIEWFINDER / 2, VIEWFINDER / 2],
});
return (
<View style={styles.root}>
<Header
title="Scan alert code"
subtitle="Point the camera at the QR code the owner shows"
onBack={() => navigation.goBack()}
right={
<Pressable
onPress={() => navigation.goBack()}
accessibilityRole="button"
accessibilityLabel="Close scanner"
style={styles.iconButton}
>
<X size={20} color={textMuted} />
</Pressable>
}
/>
<View style={styles.cameraWrap}>
{hasPermission && device ? (
<Camera
style={StyleSheet.absoluteFill}
device={device}
isActive={!joinedName}
outputs={[objectOutput]}
/>
) : (
<View style={styles.permission}>
<ScanLine size={40} color={textMuted} />
<AppText style={{ ...typeHeading, color: text, marginTop: space3 }}>
Camera access is off
</AppText>
<AppText style={styles.para}>
The camera is only used to read alert codes. You can also paste the code by hand.
</AppText>
{canRequestPermission ? (
<Button label="Allow camera" onPress={requestPermission} />
) : (
<>
<AppText style={styles.para}>
Camera permission was denied. Turn it on in Android Settings → Apps → Alarm →
Permissions.
</AppText>
<Button label="Open settings" variant="secondary" onPress={() => { openAppSettings(); }} />
</>
)}
<Button label="Enter code by hand" variant="outlineWhite" onPress={() => setShowManual(true)} />
</View>
)}
{hasPermission && !joinedName ? (
<View pointerEvents="none" style={styles.reticleWrap}>
<View style={styles.viewfinder}>
{/* Four 3px corner brackets: "aim inside this frame" without a
box that fights the camera image (DESIGN.md §7.5). */}
<View style={[styles.bracket, styles.bracketTL]} />
<View style={[styles.bracket, styles.bracketTR]} />
<View style={[styles.bracket, styles.bracketBL]} />
<View style={[styles.bracket, styles.bracketBR]} />
<Animated.View
style={[
styles.scanLine,
reduceMotion ? styles.scanLineRest : { transform: [{ translateY: sweep }] },
]}
/>
</View>
<AppText style={styles.reticleHint}>Keep the whole code inside the frame</AppText>
</View>
) : null}
{joinedName ? (
<View style={styles.joined}>
<AppText style={{ ...typeHeading, color: text }}>Subscribed</AppText>
<AppText style={styles.para}>This device will ring for {joinedName}.</AppText>
</View>
) : null}
</View>
<View style={styles.sheet}>
{error ? <ErrorBanner message={error} /> : null}
{showManual || !hasPermission || !device ? (
<View style={{ gap: space3 }}>
<Field label="Alert code or link">
<TextInputBox
value={manual}
onChangeText={setManual}
autoCapitalize="none"
autoCorrect={false}
placeholder="https://alarm.dcntrlized.org/a/…#token"
accessibilityLabel="Alert code or link"
/>
</Field>
<Button
label={busy ? 'Subscribing…' : 'Subscribe this device'}
onPress={submitManual}
disabled={busy || !manual.trim()}
/>
</View>
) : (
<Button label="Enter code by hand" variant="secondary" onPress={() => setShowManual(true)} />
)}
{targetAlertId ? (
<AppText style={styles.target}>Subscribing to {targetAlertId}</AppText>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
cameraWrap: { flex: 1, backgroundColor: '#000000', justifyContent: 'center' },
permission: { padding: space4, alignItems: 'center', gap: space2 },
para: { ...typeCaption, color: textSecondary, textAlign: 'center', marginBottom: space2 },
reticleWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
viewfinder: { width: VIEWFINDER, height: VIEWFINDER, justifyContent: 'center' },
bracket: {
position: 'absolute',
width: 40,
height: 40,
borderColor: stateIdle,
borderWidth: 3,
},
bracketTL: { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 },
bracketTR: { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 },
bracketBL: { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 },
bracketBR: { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 },
scanLine: {
height: 2,
marginHorizontal: 8,
backgroundColor: focusRing,
},
scanLineRest: { position: 'absolute', top: '50%', left: 0, right: 0 },
reticleHint: {
...typeCaption,
color: text,
marginTop: space3,
backgroundColor: scrim,
paddingHorizontal: space3,
paddingVertical: space2,
borderRadius: radiusFull,
},
joined: { alignItems: 'center', gap: space2, backgroundColor: scrim, padding: space4 },
sheet: {
padding: space4,
gap: space3,
backgroundColor: surface,
borderTopWidth: 1,
borderTopColor: border,
},
target: { ...typeLabel, color: textMuted, textAlign: 'center' },
manualHint: { ...typeBodyStrong, color: warn },
cameraIcon: { ...typeLabel, color: text },
});
+402
View File
@@ -0,0 +1,402 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Info, RefreshCw, Server, Smartphone, Wifi, WifiOff } from 'lucide-react-native';
import { useAuth } from '../state/AuthContext';
import { useAlarm } from '../state/AlarmContext';
import { useConnection } from '../state/ConnectionContext';
import {
getPermissions,
isAlarmNativeAvailable,
openExactAlarmSettings,
openFullScreenIntentSettings,
requestPermission,
startForegroundService,
stopForegroundService,
type AlarmPermissions,
} from '../services/alarmNative';
import { alarmSocketUrl } from '../services/connection';
import { AppText, Button, Card, Divider, HealthIcon, Header, OfflineStrip } from '../components/ui';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
minTapTarget,
radiusMd,
space2,
space3,
space4,
stateDisarmed,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeCode,
typeHeading,
typeLabel,
typeMicro,
warn,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; bad: string }> = {
notifications: {
title: 'Notifications',
ok: 'Allowed — alerts can post a high-priority notification.',
bad: 'Not allowed — a trigger cannot notify you while the app is closed.',
},
fullScreenIntent: {
title: 'Full-screen alarm',
ok: 'Allowed — the takeover can cover the lock screen.',
bad: 'Not allowed — the alarm will only show as a notification when locked.',
},
exactAlarm: {
title: 'Exact alarms',
ok: 'Allowed — auto-stop and scheduled work fire on time.',
bad: 'Not allowed — Android may delay auto-stop.',
},
batteryUnrestricted: {
title: 'Battery optimisation',
ok: 'Unrestricted — the armed socket keeps running in the background.',
bad: 'Optimised — Android may freeze the app and drop the armed socket.',
},
canForceVolume: {
title: 'Alarm volume',
ok: 'The alarm stream can be raised to maximum automatically.',
bad: 'The OS blocks forced volume — you will get a notification instead.',
},
};
export default function SettingsScreen() {
const navigation = useNavigation<Nav>();
const { username, email, logout } = useAuth();
const { resolved, settings, updateSettings, reprobe, probing, online, isRelease } = useConnection();
const { activeAlertId, delivery, socketState, reconnectAttempts, subscribedAlerts } = useAlarm();
const [perms, setPerms] = useState<AlarmPermissions | null>(null);
const [serviceRunning, setServiceRunning] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const refreshPerms = useCallback(async () => {
setPerms(await getPermissions());
}, []);
useEffect(() => {
refreshPerms();
}, [refreshPerms]);
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
const wsUrl = resolved && activeAlert
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '')
: null;
const toggleService = async () => {
if (!activeAlert || !wsUrl) {
setNotice('Subscribe to an alert first — the foreground service holds that alert’s socket.');
return;
}
if (serviceRunning) {
await stopForegroundService();
setServiceRunning(false);
setNotice('Foreground service stopped. The socket only lives while the app is open.');
} else {
const res = await startForegroundService(activeAlert.alertId, activeAlert.subscribeToken ?? '', wsUrl);
setServiceRunning(res.ok);
setNotice(
res.ok
? 'Foreground service running — the armed socket survives backgrounding.'
: `Could not start the service (${res.reason ?? 'unknown'}).`,
);
}
};
return (
<View style={styles.root}>
{!online ? <OfflineStrip /> : null}
<Header title="Settings & health" onBack={() => navigation.goBack()} />
<ScrollView contentContainerStyle={styles.content}>
{/* ── Delivery ─────────────────────────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Live delivery</AppText>
<Row
icon={delivery === 'socket' ? Wifi : WifiOff}
tint={delivery === 'socket' ? stateDisarmed : warn}
title={
delivery === 'socket'
? `Connected (${socketState})`
: 'Unavailable in proxy mode'
}
detail={
delivery === 'socket'
? 'Triggers arrive over the service WebSocket; the target is under a second on a LAN.'
: 'Maestro has no service WebSocket passthrough, so status is polled every 5 seconds while the app is open and every 30 seconds in the background.'
}
/>
{delivery === 'socket' && reconnectAttempts > 0 ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Reconnect attempt {reconnectAttempts} — backing off up to 30s.
</AppText>
) : null}
<Row
icon={Smartphone}
tint={isAlarmNativeAvailable ? stateDisarmed : warn}
title={isAlarmNativeAvailable ? 'Android alarm engine present' : 'Android alarm engine unavailable'}
detail={
isAlarmNativeAvailable
? 'Max-volume STREAM_ALARM playback, vibration and the lock-screen takeover are wired.'
: 'This build has no native alarm module (expected off-Android); alerts fall back to an in-app sound.'
}
/>
<Button
label={serviceRunning ? 'Stop foreground service' : 'Start foreground service'}
variant="secondary"
onPress={toggleService}
/>
{notice ? (
<AppText style={{ ...typeCaption, color: focusRing }} accessibilityLiveRegion="polite">
{notice}
</AppText>
) : null}
</View>
</Card>
{/* ── Permission health checklist ──────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<View style={styles.headRow}>
<AppText style={{ ...typeHeading, color: text }}>Permissions</AppText>
<Pressable
onPress={refreshPerms}
accessibilityRole="button"
accessibilityLabel="Re-check permissions"
style={styles.iconButton}
>
<RefreshCw size={18} color={textMuted} />
</Pressable>
</View>
{perms ? (
<>
<HealthRow
ok={perms.notifications}
onFix={() => requestPermission('notifications').then(setPerms)}
okText={HEALTH_COPY.notifications.ok}
title={HEALTH_COPY.notifications.title}
bad={HEALTH_COPY.notifications.bad}
/>
<HealthRow
ok={perms.fullScreenIntent}
onFix={() =>
openFullScreenIntentSettings().then(() =>
requestPermission('fullScreenIntent').then(setPerms),
)
}
okText={HEALTH_COPY.fullScreenIntent.ok}
title={HEALTH_COPY.fullScreenIntent.title}
bad={HEALTH_COPY.fullScreenIntent.bad}
/>
<HealthRow
ok={perms.exactAlarm}
onFix={() =>
openExactAlarmSettings().then(() => requestPermission('exactAlarm').then(setPerms))
}
okText={HEALTH_COPY.exactAlarm.ok}
title={HEALTH_COPY.exactAlarm.title}
bad={HEALTH_COPY.exactAlarm.bad}
/>
<HealthRow
ok={perms.batteryUnrestricted}
onFix={() => requestPermission('battery').then(setPerms)}
okText={HEALTH_COPY.batteryUnrestricted.ok}
title={HEALTH_COPY.batteryUnrestricted.title}
bad={HEALTH_COPY.batteryUnrestricted.bad}
/>
<HealthRow
ok={perms.canForceVolume}
onFix={undefined}
okText={HEALTH_COPY.canForceVolume.ok}
title={HEALTH_COPY.canForceVolume.title}
bad={HEALTH_COPY.canForceVolume.bad}
/>
</>
) : (
<AppText style={{ ...typeCaption, color: textSecondary }}>
Reading permission state…
</AppText>
)}
</View>
</Card>
{/* ── Connection ───────────────────────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Connection</AppText>
<Row
icon={Server}
tint={online ? stateDisarmed : warn}
title={online ? `Reachable — ${settings.mode} mode` : 'No host answered'}
detail={resolved ? resolved.baseUrl : 'Run a probe to look for the service.'}
/>
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
<View style={styles.modeRow}>
{(['maestro', 'direct'] as const).map(mode => (
<Pressable
key={mode}
onPress={() => updateSettings({ mode })}
accessibilityRole="radio"
accessibilityState={{ selected: settings.mode === mode }}
accessibilityLabel={mode === 'maestro' ? 'Maestro proxy mode' : 'Direct service mode'}
style={[styles.modeChip, settings.mode === mode ? styles.modeChipActive : null]}
>
<AppText
style={{
...typeLabel,
color: settings.mode === mode ? focusRing : textMuted,
}}
>
{mode === 'maestro' ? 'Maestro proxy' : 'Direct'}
</AppText>
</Pressable>
))}
</View>
<AppText style={{ ...typeMicro, color: textFaint }}>
Direct mode is the only mode with live WebSocket delivery. Proxy mode polls.
</AppText>
<Button label={probing ? 'Probing…' : 'Re-run connection probe'} variant="secondary" onPress={reprobe} />
{!isRelease ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Dev build · host {settings.host || '(auto)'} : {settings.port || '(default)'}
</AppText>
) : null}
</View>
</Card>
{/* ── Account ──────────────────────────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Account</AppText>
<Row
icon={Info}
tint={textMuted}
title={username ?? 'Not signed in'}
detail={email || 'Signed in on this instance'}
/>
<Button label="Sign out" variant="secondary" onPress={logout} />
</View>
</Card>
<Divider />
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
Alarm client · Dcntrlized · AGPL-3.0
</AppText>
</ScrollView>
</View>
);
}
function Row({
icon: Icon,
tint,
title,
detail,
}: {
icon: typeof Wifi;
tint: string;
title: string;
detail: string;
}) {
return (
<View style={styles.row}>
<Icon size={20} color={tint} />
<View style={{ flex: 1, gap: 2 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>{title}</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>{detail}</AppText>
</View>
</View>
);
}
function HealthRow({
ok,
title,
okText,
bad,
onFix,
}: {
ok: boolean;
title: string;
okText: string;
bad: string;
onFix?: () => void;
}) {
return (
<View style={styles.healthRow}>
<HealthIcon status={ok ? 'ok' : 'warn'} />
<View style={{ flex: 1, gap: 2 }}>
<AppText style={{ ...typeLabel, color: text }}>{title}</AppText>
<AppText style={{ ...typeCaption, color: ok ? textSecondary : warn }}>
{ok ? okText : bad}
</AppText>
</View>
{!ok && onFix ? (
<Pressable
onPress={onFix}
accessibilityRole="button"
accessibilityLabel={`Fix ${title}`}
style={styles.fixButton}
>
<AppText style={{ ...typeLabel, color: focusRing }}>Fix</AppText>
</Pressable>
) : null}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
content: { padding: space4, paddingBottom: 48, gap: space4 },
headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
row: { flexDirection: 'row', alignItems: 'flex-start', gap: space3 },
healthRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
fixButton: {
minHeight: 40,
paddingHorizontal: space3,
justifyContent: 'center',
borderRadius: radiusMd,
borderWidth: 1,
borderColor: focusRing,
},
code: {
...typeCode,
color: textMuted,
backgroundColor: surfaceRaised,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
padding: space3,
},
modeRow: { flexDirection: 'row', gap: space2 },
modeChip: {
flex: 1,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
backgroundColor: surfaceRaised,
},
modeChipActive: { borderColor: focusRing },
});
+249
View File
@@ -0,0 +1,249 @@
/**
* Alarm service API — the frozen contract, client side.
*
* `/action/<name>` calls go through `apiClient`'s `{payload: …}` envelope and
* carry `X-Access-Token`; `/auth/*` calls pass `raw = true` so the body is the
* plain JSON object (root AGENTS.md body contract).
*
* Actions: createAlert, getAlert, joinAlert, listSubscriptions,
* removeSubscription, triggerAlert, disarmAlert, alarmStatus, deleteAlert,
* qrImage.
*/
import { ApiError, del, get, post, requestBase64 } from './apiClient';
// ── Auth ─────────────────────────────────────────────────────────────────────
export interface AuthUser {
id?: string;
username?: string;
email?: string;
}
export interface AuthResponse {
accessToken?: string;
refreshToken?: string;
user?: AuthUser;
}
export interface MeResponse {
id?: string;
username?: string;
email?: string;
}
/**
* The alarm client identifies with a username (DESIGN.md §1 uses `Username`),
* so the username is sent in the identity field the auth hub accepts. The hub
* resolves either form.
*/
export function login(payload: { username: string; password: string }): Promise<AuthResponse> {
return post<AuthResponse>('/auth/login', { email: payload.username, password: payload.password }, true);
}
export function register(payload: {
username: string;
password: string;
email: string;
}): Promise<AuthResponse> {
return post<AuthResponse>('/auth/register', payload, true);
}
export function logout(refreshToken: string): Promise<unknown> {
return post<unknown>('/auth/logout', { refreshToken }, true);
}
export function me(): Promise<MeResponse> {
return get<MeResponse>('/auth/me');
}
// ── Alerts ───────────────────────────────────────────────────────────────────
export type AlertStatus = 'idle' | 'armed' | 'triggered' | 'disarmed';
/**
* The last event the service recorded for an alert (`alertView.lastEvent`). The
* client uses it to label *when* the alarm last rang or was disarmed, because
* there is no arm/enablement gate to describe instead.
*/
export interface AlertEvent {
eventId?: string;
action?: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
}
export interface Alert {
alertId: string;
name: string;
message?: string;
status: AlertStatus;
/** True while the alarm is ringing right now (server-computed, auto-stop aware). */
active?: boolean;
/** Bumped by every ``rotateSubscribeToken``. */
tokenVersion?: number;
modified?: string;
triggeredAt?: string;
disarmedAt?: string;
lastEvent?: AlertEvent;
/** Owner handle, when the service starts sending it. No actor field exists today. */
owner?: string;
ownerUsername?: string;
/** Owner-only. Never rendered in full (DESIGN.md §7.4 truncates to 12 + …). */
adminToken?: string;
subscribeToken?: string;
/** `https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}` */
qrPayload?: string;
autoStopSeconds?: number;
created?: string;
/** Client-side only: which local role this alert is held in. */
role?: 'admin' | 'subscriber';
/** Client-side only: subscription id for the subscriber role. */
subscriptionId?: string;
deviceId?: string;
deviceLabel?: string;
lastTriggered?: string;
subscriberCount?: number;
}
export interface Subscription {
subscriptionId: string;
alertId?: string;
deviceId: string;
deviceLabel?: string;
platform?: string;
joined?: string;
lastSeen?: string;
/** True when this device reported it could not use full volume. */
volumeBlocked?: boolean;
}
export interface AlarmStatus {
active: boolean;
eventId?: string;
action?: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
autoStopSeconds?: number;
}
export function createAlert(payload: {
name: string;
message?: string;
autoStopSeconds?: number;
}): Promise<Alert> {
return post<Alert>('/action/createAlert', payload);
}
export function getAlert(alertId: string): Promise<Alert> {
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`);
}
export function joinAlert(payload: {
alertId: string;
subscribeToken: string;
deviceId: string;
deviceLabel?: string;
platform?: string;
}): Promise<Subscription & { alert?: Alert }> {
return post<Subscription & { alert?: Alert }>('/action/joinAlert', payload);
}
export function listSubscriptions(alertId: string): Promise<Subscription[]> {
return get<Subscription[]>(`/action/listSubscriptions?alertId=${encodeURIComponent(alertId)}`);
}
export function removeSubscription(payload: {
alertId: string;
subscriptionId: string;
}): Promise<unknown> {
return post<unknown>('/action/removeSubscription', payload);
}
export function triggerAlert(payload: {
alertId: string;
adminToken: string;
message?: string;
}): Promise<AlarmStatus & { eventId?: string }> {
return post<AlarmStatus & { eventId?: string }>('/action/triggerAlert', payload);
}
export function disarmAlert(payload: {
alertId: string;
adminToken: string;
}): Promise<AlarmStatus> {
return post<AlarmStatus>('/action/disarmAlert', payload);
}
/** Polling fallback + catch-up, valid in both connection modes. */
export function alarmStatus(alertId: string): Promise<AlarmStatus> {
return get<AlarmStatus>(`/action/alarmStatus?alertId=${encodeURIComponent(alertId)}`);
}
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
return del<unknown>('/action/deleteAlert', payload);
}
/** Optional convenience: the service renders the PNG; resolved as base64. */
export function qrImage(alertId: string): Promise<string> {
return requestBase64('GET', `/action/qrImage?alertId=${encodeURIComponent(alertId)}`);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Map an `alarmStatus` response onto the four-state ramp. `alarmStatus` reports
* `active` + the last `action`, which is exactly enough to derive the state:
* active → triggered, last action disarm → disarmed, otherwise idle.
*/
export interface RotateResponse {
alertId: string;
subscribeToken: string;
qrPayload: string;
tokenVersion: number;
}
/**
* Rotates the subscribe token. Existing subscriptions survive — rotation
* invalidates the QR/join path, not the people who already joined — so a leaked
* code can be retired without kicking anyone off.
*/
export async function rotateSubscribeToken(input: {
alertId: string;
adminToken: string;
}): Promise<RotateResponse> {
return post<RotateResponse>('rotateSubscribeToken', input);
}
/**
* Copy for a failed join, keyed by the service's error code.
*
* `JoinAlert` answers `ERR_ALERT_TOKEN_INVALID` for a token that was rotated
* **and** for one that was never valid — the service deliberately does not
* distinguish them, so the client cannot observe which happened and must not
* claim a mechanism. "This code is no longer valid — ask the owner for a new
* one." is true of both cases and tells the user what to do next; "expired"
* would be a guess.
*/
export function joinErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'ERR_ALERT_TOKEN_INVALID') {
return 'This code is no longer valid — ask the owner for a new one.';
}
return error.message;
}
if (error instanceof Error && error.message) return error.message;
return 'Could not subscribe this device.';
}
export function statusToState(status: AlarmStatus | null | undefined): AlertStatus {
if (!status) return 'idle';
if (status.active) return 'triggered';
if (status.action === 'disarm') return 'disarmed';
return 'idle';
}
export function formatAutoStop(seconds: number | undefined): string {
if (!seconds || seconds <= 0) return 'until stopped';
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s ? `${m}m ${s}s` : `${m}m`;
}
+184
View File
@@ -0,0 +1,184 @@
import { NativeModules, Platform } from 'react-native';
/**
* JS bridge to the Android alarm engine (`com.dcntrlized.alarm.AlarmModule`).
*
* Every call is defensive: on iOS, in a JS-only test run, or on a build where
* the native module failed to register, the promise resolves to a "unavailable"
* result instead of throwing. The UI must degrade to in-app sound + a
* high-priority notification, and say so, rather than break.
*/
export interface AlarmNativeResult {
ok: boolean;
/** Android only: whether the OS actually let us take the alarm stream. */
volumeRaised?: boolean;
reason?: string;
}
/**
* The alert a native full-screen launch was for. `AlarmActivity` carries these
* extras, so a cold start from a trigger still lands on the right takeover.
*/
export interface AlarmLaunchAlert {
alertId: string;
message: string;
autoStopSeconds: number;
}
export interface AlarmPermissions {
notifications: boolean;
fullScreenIntent: boolean;
exactAlarm: boolean;
batteryUnrestricted: boolean;
/** True when the app may play at full alarm volume without a system prompt. */
canForceVolume: boolean;
}
interface AlarmNativeModule {
playAlarm(alertId: string, message: string, autoStopSeconds: number): Promise<AlarmNativeResult>;
stopAlarm(): Promise<AlarmNativeResult>;
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
stopService(): Promise<AlarmNativeResult>;
getPermissions(): Promise<AlarmPermissions>;
requestPermission(name: string): Promise<AlarmPermissions>;
getLaunchAlert(): Promise<AlarmLaunchAlert | null>;
isIgnoringBatteryOptimizations(): Promise<boolean>;
openSoundSettings(): Promise<AlarmNativeResult>;
openAppSettings(): Promise<AlarmNativeResult>;
openFullScreenIntentSettings(): Promise<AlarmNativeResult>;
openExactAlarmSettings(): Promise<AlarmNativeResult>;
}
const native = (NativeModules as Record<string, AlarmNativeModule | undefined>).AlarmModule;
export const isAlarmNativeAvailable = Platform.OS === 'android' && !!native;
const UNAVAILABLE: AlarmNativeResult = { ok: false, reason: 'native-module-unavailable' };
const PERMISSIONS_UNAVAILABLE: AlarmPermissions = {
notifications: false,
fullScreenIntent: false,
exactAlarm: false,
batteryUnrestricted: false,
canForceVolume: false,
};
export async function playAlarm(
alertId: string,
message = '',
autoStopSeconds = 0,
): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.playAlarm(alertId, message, autoStopSeconds);
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message : 'play-failed' };
}
}
export async function stopAlarm(): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.stopAlarm();
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message : 'stop-failed' };
}
}
/**
* Keeps the socket alive while armed: a foreground service with a persistent
* notification, so the trigger still lands when the app is backgrounded.
*/
export async function startForegroundService(
alertId: string,
subscribeToken: string,
wsUrl: string,
): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.startService(alertId, subscribeToken, wsUrl);
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message : 'service-failed' };
}
}
export async function stopForegroundService(): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.stopService();
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message : 'service-failed' };
}
}
/**
* Reads (and consumes) the launch intent, so a trigger that starts the process
* from cold still opens the takeover. Resolves `null` when this was an ordinary
* app launch.
*/
export async function getLaunchAlert(): Promise<AlarmLaunchAlert | null> {
if (!native) return null;
try {
const result = await native.getLaunchAlert();
return result && result.alertId ? result : null;
} catch {
return null;
}
}
export async function getPermissions(): Promise<AlarmPermissions> {
if (!native) return PERMISSIONS_UNAVAILABLE;
try {
return await native.getPermissions();
} catch {
return PERMISSIONS_UNAVAILABLE;
}
}
/** `name` is one of: notifications | fullScreenIntent | exactAlarm | battery. */
export async function requestPermission(name: string): Promise<AlarmPermissions> {
if (!native) return PERMISSIONS_UNAVAILABLE;
try {
return await native.requestPermission(name);
} catch {
return PERMISSIONS_UNAVAILABLE;
}
}
/** Opens the system volume UI — the takeover's volume-blocked recovery action. */
export async function openSoundSettings(): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.openSoundSettings();
} catch {
return UNAVAILABLE;
}
}
/** Opens this app's Android settings page — the camera-denied recovery action. */
export async function openAppSettings(): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.openAppSettings();
} catch {
return UNAVAILABLE;
}
}
export async function openFullScreenIntentSettings(): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.openFullScreenIntentSettings();
} catch {
return UNAVAILABLE;
}
}
export async function openExactAlarmSettings(): Promise<AlarmNativeResult> {
if (!native) return UNAVAILABLE;
try {
return await native.openExactAlarmSettings();
} catch {
return UNAVAILABLE;
}
}
+321
View File
@@ -0,0 +1,321 @@
/**
* Alarm WebSocket client.
*
* URL (frozen contract, direct mode only — Maestro has no service WS
* passthrough): `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
*
* Frames:
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
* "message","serverTime","autoStopSeconds"}
* client → server {"type":"ack","eventId"}
* client → server {"type":"ping"}
*
* The reconnect policy and the frame parser are pure and exported separately so
* they can be tested headlessly (no React Native, no real socket) — see
* `__tests__/alarmSocket.test.ts`.
*/
export type SocketState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';
export interface ReconnectPolicy {
/** First backoff step in ms. */
baseMs: number;
/** Backoff ceiling in ms. */
maxMs: number;
/** Multiplier applied per attempt. */
factor: number;
/** Random ± fraction applied to each delay (0..1). */
jitter: number;
}
export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = {
baseMs: 1000,
maxMs: 30000,
factor: 2,
jitter: 0.25,
};
/**
* Pure exponential-backoff state machine.
*
* 1s → 2s → 4s → 8s → 16s → 30s (cap), each step multiplied by a jitter factor
* in `[1-jitter, 1+jitter]`. `onOpened()` resets the attempt counter, so a
* healthy connection always restarts the ramp from `baseMs`.
*/
export class ReconnectMachine {
private readonly policy: ReconnectPolicy;
private readonly random: () => number;
private attempts = 0;
private state: SocketState = 'idle';
constructor(policy: Partial<ReconnectPolicy> = {}, random: () => number = Math.random) {
this.policy = { ...DEFAULT_RECONNECT_POLICY, ...policy };
this.random = random;
}
getState(): SocketState {
return this.state;
}
getAttempts(): number {
return this.attempts;
}
onConnecting(): void {
this.state = this.attempts === 0 ? 'connecting' : 'reconnecting';
}
/** A successful open resets the ramp. */
onOpened(): void {
this.attempts = 0;
this.state = 'open';
}
/**
* Register a drop and return the delay to wait before the next attempt.
* The counter increments even when the delay is capped, so the state stays
* monotonic; only the returned delay saturates.
*/
onClosed(): number {
this.attempts += 1;
this.state = 'reconnecting';
return this.delayFor(this.attempts);
}
onClosedPermanently(): void {
this.state = 'closed';
}
/** Deterministic ramp (jitter applied) for a 1-based attempt number. */
delayFor(attempt: number): number {
const { baseMs, maxMs, factor, jitter } = this.policy;
const raw = baseMs * Math.pow(factor, Math.max(0, attempt - 1));
const capped = Math.min(raw, maxMs);
if (jitter <= 0) return Math.round(capped);
const spread = capped * jitter;
const value = capped - spread + this.random() * spread * 2;
return Math.max(0, Math.round(value));
}
/** The un-jittered ramp, for tests and for the settings screen. */
nominalDelayFor(attempt: number): number {
const { baseMs, maxMs, factor } = this.policy;
return Math.min(baseMs * Math.pow(factor, Math.max(0, attempt - 1)), maxMs);
}
}
// ── Frames ───────────────────────────────────────────────────────────────────
export interface AlarmFrame {
type: 'alarm';
alertId: string;
eventId: string;
action: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
autoStopSeconds?: number;
}
export interface AckFrame {
type: 'ack';
eventId: string;
}
export interface PingFrame {
type: 'ping';
}
export type IncomingFrame = AlarmFrame | AckFrame | PingFrame | { type: 'unknown' };
/** Parse one server frame. Never throws; unknown/garbage becomes `{type:'unknown'}`. */
export function parseFrame(raw: string): IncomingFrame {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return { type: 'unknown' };
}
if (!value || typeof value !== 'object') return { type: 'unknown' };
const obj = value as Record<string, unknown>;
const type = obj.type;
if (type === 'alarm') {
if (typeof obj.alertId !== 'string' || typeof obj.eventId !== 'string') {
return { type: 'unknown' };
}
if (obj.action !== 'trigger' && obj.action !== 'disarm') {
return { type: 'unknown' };
}
return {
type: 'alarm',
alertId: obj.alertId,
eventId: obj.eventId,
action: obj.action,
message: typeof obj.message === 'string' ? obj.message : undefined,
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
autoStopSeconds:
typeof obj.autoStopSeconds === 'number' ? obj.autoStopSeconds : undefined,
};
}
if (type === 'ack') {
if (typeof obj.eventId !== 'string') return { type: 'unknown' };
return { type: 'ack', eventId: obj.eventId };
}
if (type === 'ping') return { type: 'ping' };
return { type: 'unknown' };
}
export function buildAck(eventId: string): string {
return JSON.stringify({ type: 'ack', eventId });
}
export function buildPing(): string {
return JSON.stringify({ type: 'ping' });
}
// ── Socket wrapper ───────────────────────────────────────────────────────────
export interface AlarmSocketOptions {
url: string;
/** Called for every valid `alarm` frame. */
onAlarm: (frame: AlarmFrame) => void;
onStateChange?: (state: SocketState, attempt: number) => void;
/** Heartbeat interval; 0 disables. */
pingIntervalMs?: number;
policy?: Partial<ReconnectPolicy>;
/** Injectable for tests. */
socketFactory?: (url: string) => WebSocket;
random?: () => number;
now?: () => number;
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
}
/**
* Thin reconnecting wrapper. Deliberately dependency-free apart from the global
* `WebSocket` (injectable) so the same code runs under jest.
*/
export class AlarmSocket {
private readonly opts: Required<
Pick<AlarmSocketOptions, 'url' | 'onAlarm' | 'pingIntervalMs'>
> &
AlarmSocketOptions;
private readonly machine: ReconnectMachine;
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private closedByUser = false;
constructor(options: AlarmSocketOptions) {
this.opts = { pingIntervalMs: 25000, ...options };
this.machine = new ReconnectMachine(options.policy ?? {}, options.random ?? Math.random);
}
getState(): SocketState {
return this.machine.getState();
}
connect(): void {
this.closedByUser = false;
this.open();
}
private emitState(): void {
this.opts.onStateChange?.(this.machine.getState(), this.machine.getAttempts());
}
private open(): void {
this.machine.onConnecting();
this.emitState();
const factory =
this.opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocket);
let ws: WebSocket;
try {
ws = factory(this.opts.url);
} catch {
this.scheduleReconnect();
return;
}
this.ws = ws;
ws.onopen = () => {
this.machine.onOpened();
this.emitState();
this.startPing();
};
ws.onmessage = event => {
const data = (event as { data?: unknown }).data;
if (typeof data !== 'string') return;
const frame = parseFrame(data);
if (frame.type === 'alarm') {
this.opts.onAlarm(frame);
this.ack(frame.eventId);
}
};
ws.onerror = () => {
// `onclose` always follows; nothing to do here.
};
ws.onclose = () => {
this.stopPing();
this.ws = null;
if (this.closedByUser) {
this.machine.onClosedPermanently();
this.emitState();
return;
}
this.scheduleReconnect();
};
}
private scheduleReconnect(): void {
const delay = this.machine.onClosed();
this.emitState();
const setTimer = this.opts.setTimer ?? setTimeout;
this.reconnectTimer = setTimer(() => {
this.reconnectTimer = null;
if (!this.closedByUser) this.open();
}, delay);
}
private startPing(): void {
const interval = this.opts.pingIntervalMs;
if (!interval) return;
this.stopPing();
this.pingTimer = setInterval(() => this.send(buildPing()), interval);
}
private stopPing(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
ack(eventId: string): void {
this.send(buildAck(eventId));
}
send(payload: string): boolean {
if (!this.ws || this.machine.getState() !== 'open') return false;
try {
this.ws.send(payload);
return true;
} catch {
return false;
}
}
close(): void {
this.closedByUser = true;
this.stopPing();
if (this.reconnectTimer) {
(this.opts.clearTimer ?? clearTimeout)(this.reconnectTimer);
this.reconnectTimer = null;
}
const ws = this.ws;
this.ws = null;
if (ws) {
try {
ws.close();
} catch {}
}
this.machine.onClosedPermanently();
this.emitState();
}
}
+279
View File
@@ -0,0 +1,279 @@
/**
* HTTP client for the Alarm service — house pattern from
* `calendar-client/src/services/apiClient.ts`, retargeted:
*
* - maestro mode addresses `POST {maestro}/api/actions/{serviceRef}/{action}`
* with the `{payload: …}` envelope (Maestro's action surface is HTTP-only),
* - direct mode addresses the service's own listener and uses the `{payload: …}`
* envelope for `/action/*` while `/auth/*` take raw JSON bodies
* (`raw = true`),
* - every call carries `X-Access-Token` when a session exists,
* - a 401 (or an invalid/expired-token body) triggers ONE single-flight
* `/auth/refresh` and a single retry; a failed refresh clears the session.
*/
export class ApiError extends Error {
status: number | null;
/**
* The service's `error.code` (e.g. `ERR_ALERT_TOKEN_INVALID`) when the body
* carried one. Errors are disambiguated by code, never by message text: the
* same code can mean several things to the user (see `joinErrorMessage`).
*/
code: string | null;
constructor(status: number | null, message: string, code: string | null = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
export interface ApiConfig {
baseUrl: string;
mode: 'maestro' | 'direct';
serviceRef: string | null;
token: string | null;
refreshToken: string | null;
onUnauthorized: () => void;
/** Called after a successful token refresh so the session can be persisted. */
onSessionUpdate: (token: string, refreshToken: string) => void;
}
const config: ApiConfig = {
baseUrl: '',
mode: 'direct',
serviceRef: null,
token: null,
refreshToken: null,
onUnauthorized: () => {},
onSessionUpdate: () => {},
};
export function configureApi(patch: Partial<ApiConfig>): void {
Object.assign(config, patch);
}
export function apiConfig(): Readonly<ApiConfig> {
return config;
}
function splitQuery(path: string): [string, string] {
const i = path.indexOf('?');
return i === -1 ? [path, ''] : [path.slice(0, i), path.slice(i + 1)];
}
function buildUrl(path: string): string {
if (config.mode === 'maestro') {
if (!config.serviceRef) {
throw new ApiError(null, 'Service not found through Maestro');
}
const [pathOnly, query] = splitQuery(path);
const segments = pathOnly.split('/').filter(Boolean);
const actionName = segments[segments.length - 1];
if (!actionName) throw new ApiError(null, `Invalid path: ${path}`);
let url = `${config.baseUrl}/api/actions/${encodeURIComponent(config.serviceRef)}/${actionName}`;
if (query) url += `?${query}`;
return url;
}
return `${config.baseUrl}${path}`;
}
// One in-flight refresh shared by every 401: a burst of requests that all hit
// an expired token triggers a single POST /auth/refresh and every waiter
// resolves with the same result.
let refreshPromise: Promise<boolean> | null = null;
function refreshSession(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = (async () => {
try {
if (!config.refreshToken) return false;
const url = buildUrl('/auth/refresh');
// Raw {refreshToken} in direct mode; {payload} action form in Maestro
// mode — the service proxies both (raw flag contract, root AGENTS.md).
const body =
config.mode === 'maestro'
? JSON.stringify({ payload: { refreshToken: config.refreshToken } })
: JSON.stringify({ refreshToken: config.refreshToken });
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!res.ok) return false;
const data = await res.json();
const token = data.accessToken as string | undefined;
if (!token) return false;
config.token = token;
config.refreshToken = (data.refreshToken as string | undefined) ?? config.refreshToken;
config.onSessionUpdate(token, config.refreshToken);
return true;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
}
return refreshPromise;
}
type BodyMode = 'json' | 'text' | 'base64';
async function perform<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
payload: unknown,
raw: boolean,
bodyMode: BodyMode,
): Promise<T> {
if (!config.baseUrl) {
throw new ApiError(null, 'Server not configured yet');
}
// In Maestro mode every call goes through /api/actions/{service}/{action},
// which always expects the { payload: ... } wrapper. The raw flag only
// applies when talking to a service directly.
const maestro = config.mode === 'maestro';
let body: string | undefined;
if (payload !== undefined) {
body = maestro || !raw ? JSON.stringify({ payload }) : JSON.stringify(payload);
}
const url = buildUrl(path);
for (let attempt = 0; attempt < 2; attempt++) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.token) {
headers['X-Access-Token'] = config.token;
}
let res: Response;
try {
res = await fetch(url, { method, headers, body });
} catch (e) {
console.error('apiClient fetch failed', method, path, url, e);
throw new ApiError(
null,
`Cannot reach the server (${(e as Error).name}: ${(e as Error).message})`,
);
}
if (res.ok) {
if (res.status === 204) {
return undefined as T;
}
if (bodyMode === 'text') return (await res.text()) as T;
if (bodyMode === 'base64') return (await responseToBase64(res)) as T;
return (await res.json()) as T;
}
let message = `HTTP ${res.status}`;
let code: string | null = null;
try {
const t = await res.text();
if (t) {
message = t;
// Services answer `{"error":{"code":…, "message":…}}`. Rendering that
// raw body put JSON in front of the user and made every caller guess at
// the shape — unwrap it here, once, and keep the code for the callers
// that need to distinguish cases the message cannot.
try {
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
const err = parsed?.error;
if (err && typeof err === 'object') {
if (typeof err.code === 'string') code = err.code;
if (typeof err.message === 'string' && err.message) message = err.message;
}
} catch {
// Not JSON — keep the text as-is.
}
}
} catch {}
// A 401, or any body reporting an invalid/expired token, means the session
// needs a refresh. This only applies when we actually hold a token — a 401
// on /auth/login just means wrong credentials and must surface the
// server's message instead.
const sessionExpired =
config.token !== null &&
(res.status === 401 ||
code === 'ERR_UNAUTHENTICATED' ||
code === 'ERR_TOKEN_EXPIRED' ||
code === 'ERR_SESSION_REVOKED' ||
/token has invalid claims/.test(message) ||
/token.*expired/i.test(message));
if (!sessionExpired) {
throw new ApiError(res.status, message, code);
}
// First failure: try one refresh (single-flight). A successful refresh
// rotates the tokens and the original call is retried once; a failed
// refresh always clears the session — never loop.
if (attempt === 0 && (await refreshSession())) {
continue;
}
config.onUnauthorized();
throw new ApiError(res.status, 'Session expired');
}
throw new ApiError(null, 'Request failed');
}
/**
* `qrImage` returns image/png. RN's fetch has no reliable `blob()` on Android,
* so the PNG is read through a FileReader as a data URL and the base64 part is
* returned — `react-native-svg`'s `Image` accepts `data:image/png;base64,…`.
*/
async function responseToBase64(res: Response): Promise<string> {
const blob = await res.blob();
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new ApiError(res.status, 'Could not read image response'));
reader.onload = () => {
const result = String(reader.result ?? '');
const comma = result.indexOf(',');
resolve(comma === -1 ? result : result.slice(comma + 1));
};
reader.readAsDataURL(blob);
});
}
export async function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
payload?: unknown,
raw = false,
): Promise<T> {
return perform<T>(method, path, payload, raw, 'json');
}
/** Text response variant for endpoints that are not JSON. */
export async function requestText(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
): Promise<string> {
return perform<string>(method, path, undefined, false, 'text');
}
/** Binary (PNG) response variant, resolved as base64 (see `responseToBase64`). */
export async function requestBase64(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
payload?: unknown,
): Promise<string> {
return perform<string>(method, path, payload, false, 'base64');
}
export async function get<T>(path: string): Promise<T> {
return request<T>('GET', path);
}
export async function post<T>(path: string, payload?: unknown, raw?: boolean): Promise<T> {
return request<T>('POST', path, payload, raw);
}
export async function put<T>(path: string, payload?: unknown): Promise<T> {
return request<T>('PUT', path, payload);
}
export async function del<T = void>(path: string, payload?: unknown): Promise<T> {
return request<T>('DELETE', path, payload);
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Connection resolution for the Alarm client.
*
* Mirrors `calendar-client/src/services/connection.ts` (the house pattern):
* two modes — `maestro` (the orchestrator's `/api/containers` list is probed and
* the service is addressed through `serviceRef`) and `direct` (the service's own
* `/_manifest` probe). Ports are unique per client/service so several can run at
* once. The Alarm service owns dev host port **8088**; this client's Metro runs
* on **8089** (lead ruling, 2026-10-18). They must differ: both are reached over
* `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev machine's*
* `127.0.0.1:<port>`, so a shared number would make the packager and the service
* compete for one host socket. Per-client Metro ports: keepnote=8082,
* hub=8084, calendar=8086, alarm=8089.
*
* IMPORTANT (lead ruling, 2026-10-18): Maestro has NO service WebSocket
* passthrough — its only WS is `/api/ws` for its own container-status stream and
* its action surface is HTTP-only. So live delivery over the socket is a
* direct-mode capability; in maestro mode the client polls `alarmStatus`.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export type ConnectionMode = 'maestro' | 'direct';
export interface ConnectionSettings {
mode: ConnectionMode;
host: string;
port: string;
useAdbReverse: boolean;
}
export const SERVICE_NAME = 'alarm';
export const isRelease = !__DEV__;
/** Dev machine LAN address (fallback when adb reverse is off). */
const LAN_HOST = '192.168.1.232';
export const DEFAULTS: Record<ConnectionMode, { host: string; port: string }> = {
maestro: { host: LAN_HOST, port: '8080' },
direct: { host: LAN_HOST, port: '8088' },
};
export const DEFAULT_SETTINGS: ConnectionSettings = {
mode: 'maestro',
host: '',
port: '',
useAdbReverse: true,
};
const SETTINGS_KEY = 'settings.connection';
const LAST_HOST_KEY = 'settings.lastHost';
export function defaultPort(mode: ConnectionMode): string {
return DEFAULTS[mode].port;
}
function releaseBaseUrl(): string {
return `https://${SERVICE_NAME}.dcntrlized.org`;
}
export async function loadSettings(): Promise<ConnectionSettings> {
if (isRelease) {
return { ...DEFAULT_SETTINGS, mode: 'direct', useAdbReverse: false };
}
try {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ConnectionSettings>) };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export async function saveSettings(settings: ConnectionSettings): Promise<void> {
if (isRelease) return;
try {
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {}
}
function candidateHosts(settings: ConnectionSettings, lastHost: string | null): string[] {
const explicit = settings.host.trim();
if (explicit) return [explicit];
const hosts: string[] = [];
if (lastHost) hosts.push(lastHost);
hosts.push(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
if (settings.useAdbReverse) hosts.push(LAN_HOST);
return [...new Set(hosts)];
}
async function reachable(baseUrl: string, timeoutMs: number): Promise<boolean> {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(`${baseUrl}/_manifest`, { signal: ctrl.signal });
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
interface MaestroContainer {
id: string;
name: string;
service_name?: string;
docker_name?: string;
status?: string;
}
function findServiceRef(list: MaestroContainer[]): string | null {
const needle = SERVICE_NAME.toLowerCase();
const candidates = list.filter(c => {
const svc = (c.service_name || '').toLowerCase();
const docker = (c.docker_name || '').toLowerCase();
return svc === needle || docker === needle;
});
const match = candidates.find(c => c.status === 'running') || candidates[0];
if (!match) return null;
return match.service_name || match.docker_name || match.name || match.id;
}
// Maestro does not serve /_manifest; its reachability check and the service
// container lookup both come from the /api/containers endpoint.
async function probeMaestro(
baseUrl: string,
timeoutMs: number,
): Promise<{ reachable: boolean; serviceRef: string | null }> {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(`${baseUrl}/api/containers`, { signal: ctrl.signal });
clearTimeout(timer);
if (!res.ok) return { reachable: false, serviceRef: null };
const list = (await res.json()) as MaestroContainer[];
return { reachable: true, serviceRef: findServiceRef(list) };
} catch (e) {
console.log('probeMaestro fetch failed', baseUrl, (e as Error).name, (e as Error).message);
return { reachable: false, serviceRef: null };
}
}
export interface ResolvedConnection {
baseUrl: string;
host: string;
port: string;
adbReverse: string | null;
mode: ConnectionMode;
serviceRef: string | null;
/** True when a live WebSocket is possible (direct mode only — see header). */
socketSupported: boolean;
}
export async function probe(
settings: ConnectionSettings,
timeoutMs = 2500,
): Promise<ResolvedConnection> {
if (isRelease) {
const host = `${SERVICE_NAME}.dcntrlized.org`;
return {
baseUrl: releaseBaseUrl(),
host,
port: '443',
adbReverse: null,
mode: 'direct',
serviceRef: null,
socketSupported: true,
};
}
const port = settings.port.trim() || defaultPort(settings.mode);
let lastHost: string | null = null;
try {
lastHost = await AsyncStorage.getItem(LAST_HOST_KEY);
} catch {}
const hosts = candidateHosts(settings, lastHost);
for (const host of hosts) {
const baseUrl = `http://${host}:${port}`;
if (settings.mode === 'maestro') {
const { reachable: okReach, serviceRef } = await probeMaestro(baseUrl, timeoutMs);
if (okReach) {
try {
await AsyncStorage.setItem(LAST_HOST_KEY, host);
} catch {}
return {
baseUrl,
host,
port,
adbReverse: adbReverseCommand(settings, port, host),
mode: settings.mode,
serviceRef,
socketSupported: false,
};
}
} else if (await reachable(baseUrl, timeoutMs)) {
try {
await AsyncStorage.setItem(LAST_HOST_KEY, host);
} catch {}
return {
baseUrl,
host,
port,
adbReverse: adbReverseCommand(settings, port, host),
mode: settings.mode,
serviceRef: null,
socketSupported: true,
};
}
}
const host = hosts[0];
return {
baseUrl: `http://${host}:${port}`,
host,
port,
adbReverse: adbReverseCommand(settings, port, host),
mode: settings.mode,
serviceRef: null,
socketSupported: settings.mode === 'direct',
};
}
export function adbReverseCommand(
settings: ConnectionSettings,
port?: string,
host?: string,
): string | null {
if (isRelease) return null;
const p = port || settings.port.trim() || defaultPort(settings.mode);
const h =
host ||
settings.host.trim() ||
(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
if (h !== '127.0.0.1') return null;
return `adb reverse tcp:${p} tcp:${p}`;
}
/**
* The exact WebSocket URL the client opens for an alert. Direct mode only:
* `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
* (release: `wss://alarm.dcntrlized.org/ws/alarm?...`).
*
* Returned verbatim here so it is testable headlessly and so the service team
* can diff the client's expectation against the service.
*/
export function alarmSocketUrl(
resolved: Pick<ResolvedConnection, 'baseUrl' | 'mode'>,
alertId: string,
subscribeToken: string,
): string | null {
if (resolved.mode !== 'direct') return null;
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
return `${ws}/ws/alarm?${q}`;
}
+387
View File
@@ -0,0 +1,387 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as api from '../services/alarmApi';
import type { Alert, AlertStatus, Subscription } from '../services/alarmApi';
import { useAuth } from './AuthContext';
import { useConnection } from './ConnectionContext';
import { useAlarmSocket, type LiveDelivery } from './useAlarmSocket';
import { closeLiveAlarm, openLiveAlarm } from '../navigationRef';
const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
export interface LiveAlarmEvent {
alertId: string;
eventId?: string;
action: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
autoStopSeconds?: number;
/** True when the event came in over the socket rather than a poll. */
live: boolean;
}
interface AlarmContextValue {
/** Alerts this device created (holds the admin token). */
adminAlerts: Alert[];
/** Alerts this device joined (holds the subscribe token). */
subscribedAlerts: Alert[];
loading: boolean;
error: string | null;
/** The alert whose socket/poll loop is currently active. */
activeAlertId: string | null;
setActiveAlertId: (alertId: string | null) => void;
socketState: string;
delivery: LiveDelivery;
reconnectAttempts: number;
liveEvent: LiveAlarmEvent | null;
/** Cleared when the takeover is acknowledged/dismissed. */
clearLiveEvent: () => void;
lastStatus: api.AlarmStatus | null;
refresh: () => Promise<void>;
reload: () => Promise<void>;
createAlert: (input: { name: string; message?: string; autoStopSeconds?: number }) => Promise<Alert>;
joinAlert: (input: {
alertId: string;
subscribeToken: string;
deviceLabel?: string;
}) => Promise<Alert>;
leaveAlert: (alertId: string) => Promise<void>;
forgetAdminAlert: (alertId: string) => Promise<void>;
updateAlertLocal: (alertId: string, patch: Partial<Alert>) => void;
getAlert: (alertId: string) => Alert | undefined;
adminTokenFor: (alertId: string) => string | null;
subscribeTokenFor: (alertId: string) => string | null;
deviceId: string | null;
}
const AlarmContext = createContext<AlarmContextValue | undefined>(undefined);
async function readJson<T>(key: string, fallback: T): Promise<T> {
try {
const raw = await AsyncStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T) : fallback;
} catch {
return fallback;
}
}
async function writeJson(key: string, value: unknown): Promise<void> {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch {
// Persistence is best-effort; the in-memory list stays authoritative for
// the session.
}
}
export function AlarmProvider({ children }: { children: React.ReactNode }) {
const { token } = useAuth();
const { resolved, online } = useConnection();
const [adminAlerts, setAdminAlerts] = useState<Alert[]>([]);
const [subscribedAlerts, setSubscribedAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeAlertId, setActiveAlertId] = useState<string | null>(null);
const [liveEvent, setLiveEvent] = useState<LiveAlarmEvent | null>(null);
const [deviceId, setDeviceId] = useState<string | null>(null);
// A per-install device id: stable across launches, unique per device.
useEffect(() => {
let cancelled = false;
(async () => {
let id = await AsyncStorage.getItem('alarm.deviceId.v1');
if (!id) {
id = `dev_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
await AsyncStorage.setItem('alarm.deviceId.v1', id);
}
if (!cancelled) setDeviceId(id);
})();
return () => {
cancelled = true;
};
}, []);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [admins, subs] = await Promise.all([
readJson<Alert[]>(ADMIN_ALERTS_KEY, []),
readJson<Alert[]>(SUBSCRIBED_ALERTS_KEY, []),
]);
setAdminAlerts(admins);
setSubscribedAlerts(subs);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not load your alerts.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
// Default the live loop to the first subscribed alert once data lands.
useEffect(() => {
if (activeAlertId) return;
const first = subscribedAlerts[0];
if (first) setActiveAlertId(first.alertId);
}, [subscribedAlerts, activeAlertId]);
const activeAlert = useMemo(
() => subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null,
[subscribedAlerts, activeAlertId],
);
const handleTrigger = useCallback(
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string; autoStopSeconds?: number }, live: boolean) => {
const event: LiveAlarmEvent = {
alertId: frame.alertId,
eventId: frame.eventId,
action: 'trigger',
message: frame.message,
serverTime: frame.serverTime,
autoStopSeconds: frame.autoStopSeconds,
live,
};
setLiveEvent(event);
setSubscribedAlerts(prev =>
prev.map(a =>
a.alertId === frame.alertId
? { ...a, status: 'triggered' as AlertStatus, lastTriggered: frame.serverTime }
: a,
),
);
openLiveAlarm({
alertId: frame.alertId,
eventId: frame.eventId,
message: frame.message,
serverTime: frame.serverTime,
});
},
[],
);
const handleDisarm = useCallback(
(frame: { alertId: string; eventId?: string; serverTime?: string }, live: boolean) => {
setSubscribedAlerts(prev =>
prev.map(a =>
a.alertId === frame.alertId ? { ...a, status: 'disarmed' as AlertStatus } : a,
),
);
setLiveEvent(prev => {
if (!prev || prev.alertId !== frame.alertId) return prev;
return { ...prev, action: 'disarm', eventId: frame.eventId, serverTime: frame.serverTime, live };
});
closeLiveAlarm();
},
[],
);
const { socketState, delivery, attempts, lastStatus, refresh } = useAlarmSocket({
resolved,
alertId: activeAlert?.alertId ?? null,
subscribeToken: activeAlert?.subscribeToken ?? null,
enabled: !!token && online,
onTrigger: frame => handleTrigger(frame, true),
onDisarm: frame => handleDisarm(frame, true),
});
// Poll results are a safety net: if the server says "active" and we have no
// live event for it, raise the takeover from the polled data (never gated on
// a fetch — the takeover renders from what is already in hand).
useEffect(() => {
if (!lastStatus || !activeAlert) return;
if (lastStatus.active && lastStatus.action === 'trigger') {
if (liveEvent?.alertId === activeAlert.alertId && liveEvent.action === 'trigger') return;
handleTrigger(
{
alertId: activeAlert.alertId,
eventId: lastStatus.eventId,
message: lastStatus.message,
serverTime: lastStatus.serverTime,
autoStopSeconds: lastStatus.autoStopSeconds,
},
false,
);
} else if (!lastStatus.active && liveEvent?.alertId === activeAlert.alertId) {
setLiveEvent(prev => (prev ? { ...prev, action: 'disarm' } : prev));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastStatus, activeAlert?.alertId]);
const createAlert = useCallback(
async (input: { name: string; message?: string; autoStopSeconds?: number }) => {
const created = await api.createAlert(input);
const record: Alert = { ...created, role: 'admin', status: created.status ?? 'armed' };
setAdminAlerts(prev => {
const next = [record, ...prev.filter(a => a.alertId !== record.alertId)];
writeJson(ADMIN_ALERTS_KEY, next);
return next;
});
return record;
},
[],
);
const joinAlert = useCallback(
async (input: { alertId: string; subscribeToken: string; deviceLabel?: string }) => {
if (!deviceId) throw new Error('This device is still starting up. Try again in a moment.');
const joined = await api.joinAlert({
alertId: input.alertId,
subscribeToken: input.subscribeToken,
deviceId,
deviceLabel: input.deviceLabel,
platform: 'android',
});
const record: Alert = {
...(joined.alert ?? { alertId: input.alertId, name: input.alertId, status: 'armed' as AlertStatus }),
alertId: input.alertId,
subscribeToken: input.subscribeToken,
role: 'subscriber',
subscriptionId: joined.subscriptionId,
deviceId,
deviceLabel: input.deviceLabel,
status: (joined.alert?.status as AlertStatus) ?? 'armed',
};
setSubscribedAlerts(prev => {
const next = [record, ...prev.filter(a => a.alertId !== record.alertId)];
writeJson(SUBSCRIBED_ALERTS_KEY, next);
return next;
});
setActiveAlertId(record.alertId);
return record;
},
[deviceId],
);
const leaveAlert = useCallback(
async (alertId: string) => {
const target = subscribedAlerts.find(a => a.alertId === alertId);
if (target?.subscriptionId) {
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
}
setSubscribedAlerts(prev => {
const next = prev.filter(a => a.alertId !== alertId);
writeJson(SUBSCRIBED_ALERTS_KEY, next);
return next;
});
setActiveAlertId(prev => (prev === alertId ? null : prev));
},
[subscribedAlerts],
);
const forgetAdminAlert = useCallback(async (alertId: string) => {
setAdminAlerts(prev => {
const next = prev.filter(a => a.alertId !== alertId);
writeJson(ADMIN_ALERTS_KEY, next);
return next;
});
}, []);
const updateAlertLocal = useCallback((alertId: string, patch: Partial<Alert>) => {
setAdminAlerts(prev => {
const next = prev.map(a => (a.alertId === alertId ? { ...a, ...patch } : a));
writeJson(ADMIN_ALERTS_KEY, next);
return next;
});
setSubscribedAlerts(prev => {
const next = prev.map(a => (a.alertId === alertId ? { ...a, ...patch } : a));
writeJson(SUBSCRIBED_ALERTS_KEY, next);
return next;
});
}, []);
const getAlert = useCallback(
(alertId: string) =>
adminAlerts.find(a => a.alertId === alertId) ?? subscribedAlerts.find(a => a.alertId === alertId),
[adminAlerts, subscribedAlerts],
);
const adminTokenFor = useCallback(
(alertId: string) => adminAlerts.find(a => a.alertId === alertId)?.adminToken ?? null,
[adminAlerts],
);
const subscribeTokenFor = useCallback(
(alertId: string) =>
subscribedAlerts.find(a => a.alertId === alertId)?.subscribeToken ??
adminAlerts.find(a => a.alertId === alertId)?.subscribeToken ??
null,
[adminAlerts, subscribedAlerts],
);
const value: AlarmContextValue = {
adminAlerts,
subscribedAlerts,
loading,
error,
activeAlertId,
setActiveAlertId,
socketState,
delivery,
reconnectAttempts: attempts,
liveEvent,
clearLiveEvent: () => setLiveEvent(null),
lastStatus,
refresh,
reload,
createAlert,
joinAlert,
leaveAlert,
forgetAdminAlert,
updateAlertLocal,
getAlert,
adminTokenFor,
subscribeTokenFor,
deviceId,
};
return <AlarmContext.Provider value={value}>{children}</AlarmContext.Provider>;
}
export function useAlarm(): AlarmContextValue {
const ctx = useContext(AlarmContext);
if (!ctx) throw new Error('useAlarm must be used within AlarmProvider');
return ctx;
}
/** Convenience: a subscription list refresh keyed to an alert. */
export function useSubscriptions(alertId: string | null, enabled = true) {
const [subs, setSubs] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const reload = useCallback(async () => {
if (!alertId || !enabled) return;
setLoading(true);
setError(null);
try {
const list = await api.listSubscriptions(alertId);
if (mounted.current) setSubs(Array.isArray(list) ? list : []);
} catch (e) {
if (mounted.current) setError(e instanceof Error ? e.message : 'Could not load devices.');
} finally {
if (mounted.current) setLoading(false);
}
}, [alertId, enabled]);
useEffect(() => {
reload();
}, [reload]);
return { subs, loading, error, reload };
}
+171
View File
@@ -0,0 +1,171 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { configureApi } from '../services/apiClient';
import * as api from '../services/alarmApi';
const SESSION_KEY = 'auth.session';
interface Session {
token: string;
refreshToken: string;
username: string;
email: string;
}
interface AuthContextValue {
token: string | null;
username: string | null;
email: string | null;
booting: boolean;
busy: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string, email: string) => Promise<void>;
logout: () => void;
clearSession: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [booting, setBooting] = useState(true);
const [busy, setBusy] = useState(false);
const persist = useCallback(async (s: Session) => {
setSession(s);
configureApi({ token: s.token, refreshToken: s.refreshToken });
try {
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(s));
} catch {}
}, []);
// persistTokens is invoked by the apiClient after a successful refresh so the
// rotated tokens survive an app restart.
const persistTokens = useCallback((token: string, refreshToken: string) => {
configureApi({ token, refreshToken });
setSession((prev) => {
if (!prev) return prev;
const next = { ...prev, token, refreshToken };
AsyncStorage.setItem(SESSION_KEY, JSON.stringify(next)).catch(() => {});
return next;
});
}, []);
const clearSession = useCallback(() => {
setSession(null);
configureApi({ token: null, refreshToken: null });
AsyncStorage.removeItem(SESSION_KEY).catch(() => {});
}, []);
useEffect(() => {
configureApi({ onUnauthorized: clearSession });
}, [clearSession]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const raw = await AsyncStorage.getItem(SESSION_KEY);
if (!cancelled && raw) {
const s = JSON.parse(raw) as Session;
setSession(s);
configureApi({ token: s.token, refreshToken: s.refreshToken });
// Boot revalidation (optimistic, non-blocking): hydrate the stored
// username/email from the server. If the access token expired, the
// apiClient's single-flight refresh rotates it and this retries; if
// the refresh fails the session is cleared.
api
.me()
.then((me) => {
if (cancelled) return;
const next = {
...s,
username: me.username ?? s.username,
email: me.email ?? s.email,
};
setSession(next);
AsyncStorage.setItem(SESSION_KEY, JSON.stringify(next)).catch(() => {});
})
.catch(() => {});
}
} catch {}
if (!cancelled) setBooting(false);
})();
return () => {
cancelled = true;
};
}, [persistTokens]);
const login = useCallback(
async (username: string, password: string) => {
setBusy(true);
try {
const res = await api.login({ username, password });
if (!res.accessToken) throw new Error('Login failed');
await persist({
token: res.accessToken,
refreshToken: res.refreshToken ?? '',
username: res.user?.username ?? username,
email: res.user?.email ?? '',
});
} finally {
setBusy(false);
}
},
[persist],
);
const register = useCallback(
async (username: string, password: string, email: string) => {
setBusy(true);
try {
const res = await api.register({ username, password, email });
if (!res.accessToken) throw new Error('Registration failed');
await persist({
token: res.accessToken,
refreshToken: res.refreshToken ?? '',
username: res.user?.username ?? username,
email: res.user?.email ?? email,
});
} finally {
setBusy(false);
}
},
[persist],
);
const logout = useCallback(() => {
// Optimistic: flip to the login route immediately, then revoke the refresh
// token on the server in the background. Waiting on the network here can
// leave the user stuck on the authed screens if the request hangs.
const refreshToken = session?.refreshToken;
clearSession();
if (refreshToken) api.logout(refreshToken).catch(() => {});
}, [clearSession, session]);
const value: AuthContextValue = {
token: session?.token ?? null,
username: session?.username ?? null,
email: session?.email ?? null,
booting,
busy,
login,
register,
logout,
clearSession,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+98
View File
@@ -0,0 +1,98 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { configureApi } from '../services/apiClient';
import {
isRelease,
loadSettings,
probe,
saveSettings,
type ConnectionSettings,
type ResolvedConnection,
} from '../services/connection';
interface ConnectionContextValue {
settings: ConnectionSettings;
updateSettings: (patch: Partial<ConnectionSettings>) => void;
resolved: ResolvedConnection | null;
probing: boolean;
reprobe: () => Promise<void>;
isRelease: boolean;
/** False when no candidate host answered the probe — drives the offline strip. */
online: boolean;
}
const ConnectionContext = createContext<ConnectionContextValue | undefined>(undefined);
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<ConnectionSettings>({
mode: 'maestro',
host: '',
port: '',
useAdbReverse: true,
});
const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
const [probing, setProbing] = useState(true);
const booted = useRef(false);
const runProbe = useCallback(async (s: ConnectionSettings) => {
setProbing(true);
const r = await probe(s);
setResolved(r);
configureApi({ baseUrl: r.baseUrl, mode: r.mode, serviceRef: r.serviceRef });
setProbing(false);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
const s = await loadSettings();
if (cancelled) return;
setSettings(s);
booted.current = true;
await runProbe(s);
})();
return () => {
cancelled = true;
};
}, [runProbe]);
const updateSettings = useCallback(
(patch: Partial<ConnectionSettings>) => {
setSettings((prev) => {
const next = { ...prev, ...patch };
saveSettings(next);
runProbe(next);
return next;
});
},
[runProbe],
);
const reprobe = useCallback(async () => {
await runProbe(settings);
}, [settings, runProbe]);
const value = {
settings,
updateSettings,
resolved,
probing,
reprobe,
isRelease,
online: !!resolved && !!resolved.baseUrl,
};
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
}
export function useConnection(): ConnectionContextValue {
const ctx = useContext(ConnectionContext);
if (!ctx) throw new Error('useConnection must be used within ConnectionProvider');
return ctx;
}
+56
View File
@@ -0,0 +1,56 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { Uniwind } from 'uniwind';
const THEME_KEY = 'settings.theme';
interface ThemeContextValue {
isDark: boolean;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const raw = await AsyncStorage.getItem(THEME_KEY);
const dark = raw === 'dark';
if (!cancelled) setIsDark(dark);
Uniwind.setTheme(dark ? 'dark' : 'light');
} catch {
Uniwind.setTheme('light');
}
})();
return () => {
cancelled = true;
};
}, []);
const toggleTheme = useCallback(() => {
setIsDark((prev) => {
const next = !prev;
Uniwind.setTheme(next ? 'dark' : 'light');
AsyncStorage.setItem(THEME_KEY, next ? 'dark' : 'light').catch(() => {});
return next;
});
}, []);
return <ThemeContext.Provider value={{ isDark, toggleTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+139
View File
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import {
AlarmSocket,
DEFAULT_RECONNECT_POLICY,
type AlarmFrame,
type SocketState,
} from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, type AlarmStatus } from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
export interface UseAlarmSocketOptions {
resolved: ResolvedConnection | null;
alertId: string | null;
subscribeToken: string | null;
/** Poll cadence while the app is foregrounded (spec: 5s). */
foregroundPollMs?: number;
/** Poll cadence while backgrounded (spec: 30s). */
backgroundPollMs?: number;
onTrigger: (frame: AlarmFrame) => void;
onDisarm: (frame: AlarmFrame) => void;
enabled?: boolean;
}
export interface UseAlarmSocketResult {
socketState: SocketState;
delivery: LiveDelivery;
attempts: number;
lastStatus: AlarmStatus | null;
/** Force an immediate status read (used by pull-to-refresh and Settings). */
refresh: () => Promise<void>;
}
/**
* Owns the live-alarm transport for one subscribed alert.
*
* Direct mode gets the WebSocket (the only mode Maestro can pass through);
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the
* Settings/health screen can say so out loud instead of pretending.
*/
export function useAlarmSocket({
resolved,
alertId,
subscribeToken,
foregroundPollMs = 5000,
backgroundPollMs = 30000,
onTrigger,
onDisarm,
enabled = true,
}: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle');
const [attempts, setAttempts] = useState(0);
const [lastStatus, setLastStatus] = useState<AlarmStatus | null>(null);
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
const socketRef = useRef<AlarmSocket | null>(null);
const triggerRef = useRef(onTrigger);
const disarmRef = useRef(onDisarm);
triggerRef.current = onTrigger;
disarmRef.current = onDisarm;
const url = useMemo(
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
[resolved, alertId, subscribeToken],
);
const delivery: LiveDelivery = url ? 'socket' : 'unavailable';
const refresh = useCallback(async () => {
if (!alertId) return;
try {
setLastStatus(await fetchAlarmStatus(alertId));
} catch {
// A failed status read is not fatal — the socket is the primary path and
// the next poll retries.
}
}, [alertId]);
// AppState awareness: while backgrounded the JS socket is unreliable, so the
// poll cadence relaxes to the background value and the socket is left to the
// native foreground service (which holds its own connection).
useEffect(() => {
const sub = AppState.addEventListener('change', next => setAppState(next));
return () => sub.remove();
}, []);
useEffect(() => {
if (!enabled || !url || !alertId || !subscribeToken) {
setSocketState('idle');
setAttempts(0);
return;
}
const socket = new AlarmSocket({
url,
policy: DEFAULT_RECONNECT_POLICY,
onStateChange: (state, nextAttempts) => {
setSocketState(state);
setAttempts(nextAttempts);
},
onAlarm: frame => {
if (frame.action === 'trigger') triggerRef.current(frame);
else disarmRef.current(frame);
},
});
socketRef.current = socket;
socket.connect();
return () => {
socket.close();
socketRef.current = null;
};
}, [enabled, url, alertId, subscribeToken]);
// Polling is always on while armed: it is the fallback when the socket is not
// available (maestro mode) and the safety net when it is momentarily down.
useEffect(() => {
if (!enabled || !alertId) return;
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
let cancelled = false;
const tick = async () => {
if (cancelled) return;
try {
const status = await fetchAlarmStatus(alertId);
if (!cancelled) setLastStatus(status);
} catch {
// ignore — polling must never surface a spinner
}
};
tick();
const handle = setInterval(tick, interval);
return () => {
cancelled = true;
clearInterval(handle);
};
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]);
return { socketState, delivery, attempts, lastStatus, refresh };
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Alarm palette — thin re-export of the frozen design tokens so screen code has
* one import for colours (`palette.ts` mirrors the house client shape) and one
* for everything else (`tokens.ts`).
*/
export {
bg,
surface,
surfaceRaised,
border,
text,
textMuted,
textFaint,
focusRing,
scrim,
stateIdle,
stateArmed,
stateAlarm,
stateAlarmInk,
stateAlarmDeep,
stateArmedInk,
inkOnLight,
inkOnDisarmed,
ok,
okInk,
warn,
warnInk,
danger,
dangerDeep,
inkOnFocus,
takeoverGradient,
takeoverFlat,
onAlarm,
onAlarmDim
} from './tokens';
+199
View File
@@ -0,0 +1,199 @@
/**
* Alarm design tokens — the raw literals from
* `planning/service/alarm/DESIGN.md` (frozen token contract), plus spacing,
* radius, type and motion scales.
*
* Kept as plain literals (no runtime colour math, no `color-mix()`), mirroring
* `calendar-client/src/theme/palette.ts`. Tailwind/uniwind utility classes are
* used for layout; these literals are used wherever a value must be exact
* (SVG strokes, `Animated` interpolation, `elevation`, lucide icon colours).
*/
// ── §2.1 Ground, surface, text ───────────────────────────────────────────────
export const bg = '#0B0D10';
export const surface = '#171B21';
export const surfaceRaised = '#1D222A';
/** Decorative hairlines only (1.37:1) — never the sole control boundary. */
export const border = '#262B34';
export const text = '#F2F4F7';
export const textMuted = '#A8AFBA';
export const textFaint = '#7C8593';
/**
* 13–14px secondary body copy (10.37:1 on `surface`). Use this for
* sub-headers and instruction lines; `textFaint` is the floor and must never be
* used below 13px (design-lead correction, mockup pass).
*/
export const textSecondary = '#C3C9D2';
/** Focus / selected outline AND any border that is a control boundary (8.81:1). */
export const focusRing = '#7CB0FF';
export const scrim = 'rgba(11,13,16,0.72)';
// ── §2.2 Semantic ramp ───────────────────────────────────────────────────────
export const stateIdle = '#9AA1AE';
export const stateArmed = '#FFC24B';
export const stateAlarm = '#FF5A3C';
export const stateDisarmed = '#5FE0C8';
/** Alarm text/icon on `surface` at 13px (6.95:1). */
export const stateAlarmInk = '#FF8A72';
/** Filled alarm control WITH white text (5.53:1) — never text on dark. */
export const stateAlarmDeep = '#C2341B';
/** Armed text on filled armed chips. */
export const stateArmedInk = '#FFD98A';
/** Text on filled `stateArmed` — dark-on-amber, never white. */
export const inkOnLight = '#2A0A04';
/** Text on filled `stateDisarmed`. */
export const inkOnDisarmed = '#04211C';
// ── §2.3 Status ramps ────────────────────────────────────────────────────────
export const ok = '#4ADE80';
export const okInk = '#06240F';
export const warn = '#FBBF24';
export const warnInk = '#2A1A02';
/** Destructive/irreversible (delete alert, revoke device) — NOT the alarm. */
export const danger = '#F87171';
export const dangerDeep = '#B91C1C';
/** Filled primary button text on `focusRing`. */
export const inkOnFocus = '#06121F';
// ── §2.4 Takeover gradient (flat fallback is the contract) ───────────────────
export const takeoverGradient = ['#C2341B', '#7A1B0C'] as const;
export const takeoverFlat = '#A82A15';
/** Takeover panel text (white) and its 85% variant. */
export const onAlarm = '#FFFFFF';
export const onAlarmDim = 'rgba(255,255,255,0.85)';
// ── §3 Typography (px at 1× density; RN `fontSize`) ──────────────────────────
export interface TypeStyle {
fontSize: number;
lineHeight: number;
fontWeight: '400' | '500' | '600' | '700';
letterSpacing: number;
uppercase?: boolean;
fontFamily?: string;
}
export const typeDisplay: TypeStyle = { fontSize: 34, lineHeight: 38, fontWeight: '700', letterSpacing: -0.4 };
export const typeTitle: TypeStyle = { fontSize: 24, lineHeight: 30, fontWeight: '700', letterSpacing: -0.2 };
export const typeHeading: TypeStyle = { fontSize: 18, lineHeight: 24, fontWeight: '600', letterSpacing: 0 };
export const typeBody: TypeStyle = { fontSize: 16, lineHeight: 24, fontWeight: '400', letterSpacing: 0 };
export const typeBodyStrong: TypeStyle = { fontSize: 16, lineHeight: 24, fontWeight: '600', letterSpacing: 0 };
export const typeLabel: TypeStyle = { fontSize: 14, lineHeight: 20, fontWeight: '500', letterSpacing: 0 };
export const typeCaption: TypeStyle = { fontSize: 13, lineHeight: 18, fontWeight: '400', letterSpacing: 0 };
export const typeMicro: TypeStyle = {
fontSize: 12,
lineHeight: 16,
fontWeight: '600',
letterSpacing: 0.6,
uppercase: true,
};
export const typeCode: TypeStyle = {
fontSize: 14,
lineHeight: 20,
fontWeight: '500',
letterSpacing: 0.4,
fontFamily: 'monospace',
};
// ── §4 Spacing, radius, elevation ────────────────────────────────────────────
export const space1 = 4;
export const space2 = 8;
export const space3 = 12;
export const space4 = 16;
export const space5 = 20;
export const space6 = 24;
export const space8 = 32;
export const space10 = 40;
export const space12 = 48;
export const radiusSm = 8;
export const radiusMd = 12;
export const radiusLg = 16;
export const radiusXl = 24;
export const radiusFull = 999;
/** Minimum tap target (§4). */
export const minTapTarget = 48;
export const elev0 = {};
export const elev1 = {
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.24,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
};
export const elev2 = {
elevation: 12,
shadowColor: '#000',
shadowOpacity: 0.32,
shadowRadius: 20,
shadowOffset: { width: 0, height: 8 },
};
// ── §5 Motion ────────────────────────────────────────────────────────────────
export const motionInstant = 80;
export const motionQuick = 160;
export const motionStandard = 240;
export const motionSettle = 320;
export const motionBreathe = 1800;
export const motionPulse = 700;
export const motionSiren = 900;
/** Hold-to-trigger duration (§7.3). */
export const triggerHoldMs = 1200;
/** Easing approximations for RN's `Animated` (cubic-bezier(0.2,0.8,0.2,1)). */
export const easeStandard = { duration: motionStandard, useNativeDriver: true } as const;
// ── §2.2 State meta (icon + label + colour + shape) ──────────────────────────
export type AlarmState = 'idle' | 'armed' | 'triggered' | 'disarmed';
export interface StateMeta {
label: string;
color: string;
/** Filled-chip text colour, when the pill is filled. */
chipText: string;
chipFill: string | null;
/** lucide icon name used by `StatePill` / state ring. */
icon: 'circle-dashed' | 'shield-check' | 'siren' | 'bell-off';
ringWidth: number;
dashed: boolean;
}
export const STATE_META: Record<AlarmState, StateMeta> = {
idle: {
label: 'IDLE',
color: stateIdle,
chipText: inkOnLight,
chipFill: stateIdle,
icon: 'circle-dashed',
ringWidth: 1,
dashed: true,
},
armed: {
label: 'ARMED',
color: stateArmed,
chipText: inkOnLight,
chipFill: stateArmed,
icon: 'shield-check',
ringWidth: 2,
dashed: false,
},
triggered: {
label: 'TRIGGERED',
color: stateAlarm,
chipText: '#FFFFFF',
chipFill: stateAlarmDeep,
icon: 'siren',
ringWidth: 4,
dashed: false,
},
disarmed: {
label: 'DISARMED',
color: stateDisarmed,
chipText: inkOnDisarmed,
chipFill: stateDisarmed,
icon: 'bell-off',
ringWidth: 2,
dashed: false,
},
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Minimal ambient types for `qrcode` (the pure-JS encoder).
*
* The package ships no bundled declarations and `@types/qrcode` is not a
* dependency of this client, so only the surface we actually use is declared:
* `create()` returning the module matrix, plus the two `toDataURL`/`toString`
* helpers kept for completeness.
*/
declare module 'qrcode' {
export interface QRCodeModules {
size: number;
data: Uint8Array;
}
export interface QRCodeSymbol {
modules: QRCodeModules;
version: number;
errorCorrectionLevel: { bit: number };
maskPattern: number;
segments: unknown[];
}
export interface QRCodeOptions {
version?: number;
errorCorrectionLevel?: 'low' | 'medium' | 'quartile' | 'high' | 'L' | 'M' | 'Q' | 'H';
maskPattern?: number;
toSJISFunc?: (codePoint: string) => number;
}
export interface RenderOptions {
margin?: number;
width?: number;
color?: { dark?: string; light?: string };
}
export function create(text: string, options?: QRCodeOptions): QRCodeSymbol;
export function toDataURL(text: string, options?: RenderOptions): Promise<string>;
export function toString(text: string, options?: RenderOptions & { type?: string }): Promise<string>;
const QRCode: {
create: typeof create;
toDataURL: typeof toDataURL;
toString: typeof toString;
};
export default QRCode;
}
+10
View File
@@ -0,0 +1,10 @@
// NOTE: This file is generated by uniwind and it should not be edited manually.
/// <reference types="uniwind/types" />
declare module 'uniwind' {
export interface UniwindConfig {
themes: readonly ['light', 'dark']
}
}
export {}