init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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)}…`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
Vendored
+46
@@ -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;
|
||||
}
|
||||
Vendored
+10
@@ -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 {}
|
||||
Reference in New Issue
Block a user