Files
alarm-client/src/screens/AlertDetailScreen.tsx
T
2026-10-18 22:42:00 +01:00

628 lines
21 KiB
TypeScript

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 },
});