This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+627
View File
@@ -0,0 +1,627 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
AlertTriangle,
BellOff,
Copy,
QrCode,
RefreshCw,
ScanLine,
ShieldCheck,
Smartphone,
Trash2,
Wifi,
WifiOff,
} from 'lucide-react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { useAlarm, useSubscriptions } from '../state/AlarmContext';
import { useConnection } from '../state/ConnectionContext';
import * as api from '../services/alarmApi';
import { formatAutoStop } from '../services/alarmApi';
import { relativeSince } from '../lib/time';
import { truncateToken } from '../lib/qrPayload';
import { TriggerPad } from '../components/TriggerPad';
import {
AppText,
Button,
Card,
Divider,
EmptyState,
ErrorBanner,
Field,
Header,
IconLine,
LoadingView,
OfflineStrip,
StatePill,
StateRing,
TextInputBox,
} from '../components/ui';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
danger,
focusRing,
minTapTarget,
radiusLg,
radiusMd,
space1,
space2,
space3,
space4,
space6,
stateArmed,
stateDisarmed,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeCode,
typeHeading,
typeMicro,
warn,
type AlarmState,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'AlertDetail'>;
const NEW = 'new';
export default function AlertDetailScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const alertId = params?.alertId ?? NEW;
if (alertId === NEW) return <CreateAlertView onCreated={id => navigation.replace('AlertDetail', { alertId: id, role: 'admin' })} />;
return <AlertDetailView alertId={alertId} role={params?.role} />;
}
// ── Create ───────────────────────────────────────────────────────────────────
function CreateAlertView({ onCreated }: { onCreated: (alertId: string) => void }) {
const navigation = useNavigation<Nav>();
const { createAlert } = useAlarm();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [autoStop, setAutoStop] = useState('60');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
setError(null);
try {
const seconds = Number.parseInt(autoStop, 10);
const created = await createAlert({
name: name.trim(),
message: message.trim() || undefined,
autoStopSeconds: Number.isFinite(seconds) && seconds > 0 ? seconds : undefined,
});
onCreated(created.alertId);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create the alert.');
} finally {
setBusy(false);
}
};
return (
<View style={styles.root}>
<Header title="New alert" subtitle="Create the alert, then show its code" onBack={() => navigation.goBack()} />
<ScrollView contentContainerStyle={styles.content}>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>What is this alert for?</AppText>
<Field label="Alert name">
<TextInputBox
value={name}
onChangeText={setName}
placeholder="Front door"
accessibilityLabel="Alert name"
/>
</Field>
<Field label="Message (optional)">
<TextInputBox
value={message}
onChangeText={setMessage}
placeholder="Come to the front door"
accessibilityLabel="Message"
/>
</Field>
<Field label="Auto-stop (seconds)">
<TextInputBox
value={autoStop}
onChangeText={setAutoStop}
keyboardType="number-pad"
placeholder="60"
accessibilityLabel="Auto-stop in seconds"
/>
</Field>
<AppText style={{ ...typeCaption, color: textSecondary }}>
{formatAutoStop(Number.parseInt(autoStop, 10) || undefined)}. The alarm stops on its own
after this, on every device.
</AppText>
</View>
</Card>
{error ? <ErrorBanner message={error} onRetry={submit} /> : null}
<Button
label={busy ? 'Creating…' : 'Create alert'}
icon={ShieldCheck}
onPress={submit}
disabled={busy || !name.trim()}
/>
<AppText style={styles.note}>
The owner token is shown once, right after creation. Store it before you leave the next
screen — it cannot be recovered.
</AppText>
</ScrollView>
</View>
);
}
// ── Detail ───────────────────────────────────────────────────────────────────
function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | 'subscriber' }) {
const navigation = useNavigation<Nav>();
const { online } = useConnection();
const {
adminAlerts,
getAlert,
adminTokenFor,
updateAlertLocal,
forgetAdminAlert,
leaveAlert,
liveEvent,
refresh,
setActiveAlertId,
delivery,
socketState,
} = useAlarm();
const alert = getAlert(alertId);
const isAdmin = role === 'admin' || !!adminAlerts.find(a => a.alertId === alertId);
const adminToken = adminTokenFor(alertId);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [revealToken, setRevealToken] = useState(false);
const [serverAlert, setServerAlert] = useState<api.Alert | null>(null);
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
isAdmin ? alertId : null,
isAdmin,
);
// Read the authoritative state on mount; the local copy is only a cache.
const loadServerAlert = useCallback(async () => {
try {
const fresh = await api.getAlert(alertId);
setServerAlert(fresh);
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
} catch {
// Offline is fine — the cached copy renders.
}
}, [alertId, updateAlertLocal]);
useEffect(() => {
setActiveAlertId(isAdmin ? null : alertId);
loadServerAlert();
}, [alertId, isAdmin, loadServerAlert, setActiveAlertId]);
// `AlertStatus` IS the four-state ramp — no mapping needed.
const state: AlarmState = serverAlert?.status ?? alert?.status ?? 'idle';
// `armed`/`disarmed` are ringing-state history, not an enablement gate: there
// is no arm control and no `armAlert` action, and the service accepts a
// trigger from any state. So the copy labels the last event instead of
// promising a re-arm that does not exist.
const lastEventAt = serverAlert?.lastEvent?.serverTime ?? serverAlert?.triggeredAt ?? serverAlert?.disarmedAt;
const lastEventAgo = relativeSince(lastEventAt, Date.now());
const stateSentence =
state === 'triggered'
? 'Ringing now on every subscribed device.'
: state === 'disarmed'
? lastEventAgo
? `Disarmed ${lastEventAgo} — it will ring again the moment the owner triggers it.`
: 'Disarmed — it will ring again the moment the owner triggers it.'
: state === 'armed'
? lastEventAgo
? `Armed ${lastEventAgo} — triggering rings every subscribed device.`
: 'Armed — triggering rings every subscribed device.'
: 'Idle — triggering rings every subscribed device.';
const live = liveEvent?.alertId === alertId && liveEvent.action === 'trigger';
const effectiveState = live ? 'triggered' : state;
const deviceCount = subs.length;
const doTrigger = async () => {
if (!adminToken) return;
setBusy(true);
setError(null);
try {
const res = await api.triggerAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'triggered', lastTriggered: res.serverTime });
setServerAlert(prev => (prev ? { ...prev, status: 'triggered' } : prev));
setNotice(`Triggered on ${deviceCount} device${deviceCount === 1 ? '' : 's'}.`);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not trigger the alarm.');
} finally {
setBusy(false);
}
};
const doDisarm = async () => {
if (!adminToken) return;
setBusy(true);
setError(null);
try {
await api.disarmAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'disarmed' });
setServerAlert(prev => (prev ? { ...prev, status: 'disarmed' } : prev));
setNotice('Disarmed on every device.');
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not disarm the alarm.');
} finally {
setBusy(false);
}
};
const doDelete = async () => {
if (!adminToken) return;
setBusy(true);
setError(null);
try {
await api.deleteAlert({ alertId, adminToken });
await forgetAdminAlert(alertId);
navigation.goBack();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not delete the alert.');
} finally {
setBusy(false);
}
};
const doLeave = async () => {
setBusy(true);
setError(null);
try {
await leaveAlert(alertId);
navigation.goBack();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not leave this alert.');
} finally {
setBusy(false);
}
};
const copy = async (value: string, label: string) => {
try {
Clipboard.setString(value);
setNotice(`${label} copied.`);
} catch {
setNotice('Could not copy — long-press the value to select it.');
}
};
if (!alert && !serverAlert) {
return (
<View style={styles.root}>
<Header title="Alert" onBack={() => navigation.goBack()} />
<EmptyState
icon={AlertTriangle}
title="Alert not found on this device"
subtitle="This device has no token for that alert. Scan its QR code again to rejoin."
action={<Button label="Scan a QR code" icon={ScanLine} onPress={() => navigation.navigate('Scanner')} />}
/>
</View>
);
}
return (
<View style={styles.root}>
{!online ? <OfflineStrip /> : null}
<Header
title={alert?.name ?? serverAlert?.name ?? alertId}
subtitle={isAdmin ? 'Owner controls' : 'Subscribed device'}
onBack={() => navigation.goBack()}
right={
<Pressable
onPress={refresh}
accessibilityRole="button"
accessibilityLabel="Refresh status"
style={styles.iconButton}
>
<RefreshCw size={18} color={textMuted} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{error ? <ErrorBanner message={error} onRetry={loadServerAlert} /> : null}
{notice ? (
<View style={styles.notice} accessibilityLiveRegion="polite">
<AppText style={{ ...typeCaption, color: stateDisarmed }}>{notice}</AppText>
</View>
) : null}
<View style={styles.ringWrap}>
<StateRing state={effectiveState} />
<StatePill state={effectiveState} style={{ marginTop: space4 }} />
<AppText style={styles.stateLine}>{stateSentence}</AppText>
</View>
{isAdmin ? (
<AdminControls
adminToken={adminToken}
revealToken={revealToken}
onRevealToken={() => setRevealToken(true)}
onCopy={copy}
deviceCount={deviceCount}
busy={busy}
ringing={effectiveState === 'triggered'}
onTrigger={doTrigger}
onDisarm={doDisarm}
onShowQr={() => navigation.navigate('QrDisplay', { alertId })}
autoStopSeconds={serverAlert?.autoStopSeconds ?? alert?.autoStopSeconds}
/>
) : (
<SubscriberControls
alertId={alertId}
alert={alert}
delivery={delivery}
socketState={socketState}
busy={busy}
onLeave={doLeave}
/>
)}
{isAdmin ? (
<View style={{ gap: space3 }}>
<View style={styles.sectionHead}>
<AppText style={{ ...typeHeading, color: text }}>Subscribed devices</AppText>
<AppText style={{ ...typeMicro, color: textFaint }}>{deviceCount}</AppText>
</View>
{subsError ? <ErrorBanner message={subsError} onRetry={reloadSubs} /> : null}
{subsLoading && !subs.length ? (
<LoadingView label="Loading devices…" />
) : subs.length === 0 ? (
<Card>
<IconLine icon={Smartphone} color={textMuted}>
No device has scanned this alert yet. Show the QR code to subscribe one.
</IconLine>
</Card>
) : (
subs.map(sub => (
<Card key={sub.subscriptionId}>
<View style={styles.subRow}>
<View style={{ flex: 1, gap: space1 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>
{sub.deviceLabel || sub.deviceId}
</AppText>
<AppText style={{ ...typeMicro, color: textFaint }}>
{sub.platform ?? 'android'} · joined {sub.joined ?? 'recently'}
</AppText>
</View>
<Pressable
onPress={async () => {
try {
await api.removeSubscription({ alertId, subscriptionId: sub.subscriptionId });
reloadSubs();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not revoke that device.');
}
}}
accessibilityRole="button"
accessibilityLabel={`Revoke ${sub.deviceLabel || sub.deviceId}`}
style={styles.iconButton}
>
<Trash2 size={18} color={danger} />
</Pressable>
</View>
</Card>
))
)}
</View>
) : null}
<Divider />
{/* Destructive ramp is separate from the alarm ramp (DESIGN.md §2.3). */}
{isAdmin ? (
<View style={{ gap: space2 }}>
<Button
label="Delete alert"
variant="danger"
icon={Trash2}
disabled={busy}
onPress={doDelete}
/>
<AppText style={styles.note}>
Deleting removes the alert and its tokens. Every subscribed device stops receiving it.
</AppText>
</View>
) : (
<View style={{ gap: space2 }}>
<Button label="Leave this alert" variant="danger" icon={BellOff} disabled={busy} onPress={doLeave} />
<AppText style={styles.note}>
Leaving removes this device from the alert. You can rejoin with the QR code.
</AppText>
</View>
)}
</ScrollView>
</View>
);
}
function AdminControls({
adminToken,
revealToken,
onRevealToken,
onCopy,
deviceCount,
busy,
ringing,
onTrigger,
onDisarm,
onShowQr,
autoStopSeconds,
}: {
adminToken: string | null;
revealToken: boolean;
onRevealToken: () => void;
onCopy: (value: string, label: string) => void;
deviceCount: number;
busy: boolean;
/** Does the alarm ring on other devices right now (drives the pad's copy). */
ringing: boolean;
onTrigger: () => void;
onDisarm: () => void;
onShowQr: () => void;
autoStopSeconds?: number;
}) {
return (
<View style={{ gap: space4 }}>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Owner token</AppText>
{adminToken ? (
<>
<Pressable
onPress={() => (revealToken ? onCopy(adminToken, 'Owner token') : onRevealToken())}
accessibilityRole="button"
accessibilityLabel={revealToken ? 'Copy owner token' : 'Reveal owner token'}
style={styles.tokenBox}
>
<AppText style={{ ...typeCode, color: text }}>
{revealToken ? truncateToken(adminToken, 24) : truncateToken(adminToken)}
</AppText>
<Copy size={16} color={textFaint} />
</Pressable>
<AppText style={{ ...typeMicro, color: textFaint }}>
{revealToken ? 'Tap to copy' : 'Tap to reveal the first 24 characters'}
</AppText>
</>
) : (
<IconLine icon={AlertTriangle} color={warn}>
No owner token is stored on this device, so this alert cannot be triggered from here.
</IconLine>
)}
</View>
</Card>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Control</AppText>
<TriggerPad
ringing={ringing}
onTrigger={onTrigger}
onDisarm={onDisarm}
disabled={busy || !adminToken}
disabledReason={
!adminToken
? 'This device has no owner token, so it cannot trigger the alarm.'
: busy
? 'Working…'
: undefined
}
deviceCount={deviceCount}
/>
{autoStopSeconds ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Auto-stops after {formatAutoStop(autoStopSeconds)} on every device.
</AppText>
) : null}
</View>
</Card>
<Card onPress={onShowQr} accessibilityLabel="Show the QR code to subscribe a device">
<View style={styles.qrRow}>
<QrCode size={22} color={focusRing} />
<View style={{ flex: 1 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>Show QR code</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
Subscribes a device in one scan
</AppText>
</View>
</View>
</Card>
</View>
);
}
function SubscriberControls({
alert,
delivery,
socketState,
busy,
onLeave,
}: {
alertId: string;
alert: api.Alert | undefined;
delivery: string;
socketState: string;
busy: boolean;
onLeave: () => void;
}) {
return (
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>This device</AppText>
<IconLine icon={delivery === 'socket' ? Wifi : WifiOff} color={delivery === 'socket' ? stateDisarmed : warn}>
{delivery === 'socket'
? `Connected for live delivery (${socketState}).`
: 'Live delivery unavailable in proxy mode — status is polled every 5 seconds.'}
</IconLine>
<IconLine icon={ShieldCheck} color={stateArmed}>
{alert?.deviceLabel ?? 'This device'} rings at maximum volume when the owner triggers the alert.
</IconLine>
<Button label="Leave" variant="secondary" disabled={busy} onPress={onLeave} />
</View>
</Card>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
content: { padding: space4, paddingBottom: 48, gap: space4 },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
notice: {
borderWidth: 1,
borderColor: 'rgba(95,224,200,0.3)',
backgroundColor: 'rgba(95,224,200,0.1)',
borderRadius: radiusLg,
padding: space3,
},
ringWrap: { alignItems: 'center', paddingVertical: space6 },
stateLine: { ...typeCaption, color: textSecondary, textAlign: 'center', marginTop: space3 },
sectionHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' },
subRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
tokenBox: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: space3,
backgroundColor: surfaceRaised,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
paddingHorizontal: space4,
minHeight: minTapTarget,
},
qrRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
note: { ...typeCaption, color: textSecondary },
});
+354
View File
@@ -0,0 +1,354 @@
import React, { useState } from 'react';
import { Alert as RNAlert, Pressable, RefreshControl, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
BellRing,
ChevronRight,
Plus,
QrCode,
ScanLine,
Settings as SettingsIcon,
ShieldCheck,
Siren,
} from 'lucide-react-native';
import { useAlarm } from '../state/AlarmContext';
import { useAuth } from '../state/AuthContext';
import { useConnection } from '../state/ConnectionContext';
import {
AppText,
Button,
Card,
EmptyState,
ErrorBanner,
LoadingView,
OfflineStrip,
StatePill,
} from '../components/ui';
import type { RootStackParamList } from '../navigation';
import type { Alert } from '../services/alarmApi';
import {
bg,
border,
focusRing,
minTapTarget,
radiusFull,
radiusLg,
space2,
space3,
space4,
space6,
space10,
stateAlarm,
surface,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeLabel,
typeMicro,
typeTitle,
type AlarmState,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Tab = 'admin' | 'subscribed';
export default function HomeScreen() {
const navigation = useNavigation<Nav>();
const { username, logout } = useAuth();
const { online } = useConnection();
const {
adminAlerts,
subscribedAlerts,
loading,
error,
reload,
liveEvent,
delivery,
socketState,
} = useAlarm();
const [tab, setTab] = useState<Tab>('admin');
const [refreshing, setRefreshing] = useState(false);
const onRefresh = async () => {
setRefreshing(true);
await reload();
setRefreshing(false);
};
const list = tab === 'admin' ? adminAlerts : subscribedAlerts;
return (
<View style={styles.root}>
{!online ? <OfflineStrip /> : null}
<View style={styles.header}>
<View style={{ flex: 1 }}>
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
{username ? `Signed in as ${username}` : 'Max-volume alerts for your devices'}
</AppText>
</View>
<Pressable
onPress={() => navigation.navigate('Settings')}
accessibilityRole="button"
accessibilityLabel="Settings and health"
style={styles.iconButton}
>
<SettingsIcon size={22} color={textMuted} />
</Pressable>
</View>
{/* Live banner: only while an alarm is actually up. */}
{liveEvent?.action === 'trigger' ? (
<Pressable
onPress={() => navigation.navigate('LiveAlarm', { alertId: liveEvent.alertId })}
accessibilityRole="button"
accessibilityLabel="An alarm is live. Open the takeover."
style={styles.liveBanner}
>
<Siren size={20} color="#FFFFFF" />
<AppText style={{ ...typeLabel, color: '#FFFFFF', flex: 1 }}>
Alarm live on {liveEvent.alertId}
</AppText>
<ChevronRight size={18} color="#FFFFFF" />
</Pressable>
) : null}
<View style={styles.tabs}>
<TabButton
label="My alerts"
icon={ShieldCheck}
active={tab === 'admin'}
count={adminAlerts.length}
onPress={() => setTab('admin')}
/>
<TabButton
label="Subscribed"
icon={BellRing}
active={tab === 'subscribed'}
count={subscribedAlerts.length}
onPress={() => setTab('subscribed')}
/>
</View>
<ScrollView
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{error ? <ErrorBanner message={error} onRetry={reload} /> : null}
{loading && !list.length ? (
<LoadingView label="Loading your alerts…" />
) : list.length === 0 ? (
tab === 'admin' ? (
<EmptyState
icon={ShieldCheck}
title="No alerts yet"
subtitle="Create an alert, show its QR code, and every device that scans it will ring when you trigger it."
action={
<Button
label="Create an alert"
icon={Plus}
onPress={() => navigation.navigate('AlertDetail', { alertId: 'new' })}
/>
}
/>
) : (
<EmptyState
icon={QrCode}
title="Not subscribed to anything"
subtitle="Scan the QR code the alert owner shows you, or paste the code by hand."
action={
<Button
label="Scan a QR code"
icon={ScanLine}
onPress={() => navigation.navigate('Scanner')}
/>
}
/>
)
) : (
<View style={{ gap: space3 }}>
{list.map(alert => (
<AlertRow
key={alert.alertId}
alert={alert}
live={liveEvent?.alertId === alert.alertId && liveEvent.action === 'trigger'}
onPress={() =>
navigation.navigate('AlertDetail', { alertId: alert.alertId, role: alert.role })
}
/>
))}
{tab === 'subscribed' ? (
<AppText style={styles.deliveryNote}>
{delivery === 'socket'
? `Live delivery connected (${socketState}).`
: 'Live delivery unavailable in proxy mode — status is polled every 5s.'}
</AppText>
) : null}
</View>
)}
<View style={styles.footerActions}>
{tab === 'admin' ? (
<Button
label="New alert"
icon={Plus}
variant="secondary"
onPress={() => navigation.navigate('AlertDetail', { alertId: 'new' })}
/>
) : null}
<Button
label="Scan a QR code"
icon={ScanLine}
variant="secondary"
onPress={() => navigation.navigate('Scanner')}
/>
<Button
label="Sign out"
variant="ghost"
height={44}
onPress={() =>
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign out', style: 'destructive', onPress: logout },
])
}
/>
</View>
</ScrollView>
</View>
);
}
function TabButton({
label,
icon: Icon,
active,
count,
onPress,
}: {
label: string;
icon: typeof ShieldCheck;
active: boolean;
count: number;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
accessibilityLabel={`${label}, ${count} alert${count === 1 ? '' : 's'}`}
style={[styles.tab, active ? styles.tabActive : null]}
>
<Icon size={16} color={active ? focusRing : textMuted} />
<AppText style={{ ...typeLabel, color: active ? focusRing : textMuted }}>{label}</AppText>
{count > 0 ? (
<View style={[styles.badge, active ? styles.badgeActive : null]}>
<AppText style={{ ...typeMicro, color: active ? '#06121F' : textMuted }}>{count}</AppText>
</View>
) : null}
</Pressable>
);
}
function AlertRow({ alert, live, onPress }: { alert: Alert; live: boolean; onPress: () => void }) {
const state: AlarmState = live ? 'triggered' : alert.status;
const Icon = alert.role === 'admin' ? ShieldCheck : BellRing;
return (
<Card onPress={onPress} accessibilityLabel={`${alert.name}. Open alert detail.`}>
<View style={styles.row}>
<View style={[styles.rowIcon, live ? { borderColor: stateAlarm } : null]}>
<Icon size={20} color={live ? stateAlarm : textMuted} />
</View>
<View style={{ flex: 1, gap: space2 }}>
<AppText style={{ ...typeBodyStrong, color: text }} numberOfLines={1}>
{alert.name || alert.alertId}
</AppText>
<View style={styles.rowMeta}>
<StatePill state={state} />
<AppText style={{ ...typeMicro, color: textFaint }}>
{alert.role === 'admin' ? 'Owner' : 'Subscribed'}
</AppText>
</View>
</View>
<ChevronRight size={20} color={textFaint} />
</View>
</Card>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
paddingHorizontal: space4,
paddingTop: space4,
paddingBottom: space3,
},
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
liveBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
backgroundColor: stateAlarm,
marginHorizontal: space4,
marginBottom: space3,
paddingHorizontal: space4,
paddingVertical: space3,
borderRadius: radiusLg,
},
tabs: { flexDirection: 'row', gap: space2, paddingHorizontal: space4, paddingBottom: space3 },
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
minHeight: minTapTarget,
borderRadius: radiusLg,
backgroundColor: surfaceRaised,
borderWidth: 1,
borderColor: border,
},
tabActive: { borderColor: focusRing },
badge: {
minWidth: 20,
height: 20,
paddingHorizontal: 6,
borderRadius: radiusFull,
backgroundColor: surface,
alignItems: 'center',
justifyContent: 'center',
},
badgeActive: { backgroundColor: focusRing },
content: { paddingHorizontal: space4, paddingBottom: space10 },
row: { flexDirection: 'row', alignItems: 'center', gap: space3 },
rowIcon: {
width: 40,
height: 40,
borderRadius: radiusFull,
borderWidth: 2,
borderColor: border,
alignItems: 'center',
justifyContent: 'center',
},
rowMeta: { flexDirection: 'row', alignItems: 'center', gap: space2 },
deliveryNote: { ...typeCaption, color: textSecondary, paddingTop: space2 },
footerActions: { gap: space2, paddingTop: space6 },
});
+397
View File
@@ -0,0 +1,397 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
Animated,
BackHandler,
Easing,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BellOff, OctagonX, Siren, VolumeX } from 'lucide-react-native';
import { useAlarm } from '../state/AlarmContext';
import { useAuth } from '../state/AuthContext';
import { AppText } from '../components/ui';
import * as api from '../services/alarmApi';
import { openSoundSettings, playAlarm, stopAlarm, isAlarmNativeAvailable } from '../services/alarmNative';
import { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
import type { RootStackParamList } from '../navigation';
import {
motionSiren,
radiusFull,
space2,
space3,
space4,
space6,
takeoverFlat,
onAlarm,
onAlarmDim,
typeBodyStrong,
typeCaption,
typeDisplay,
typeHeading,
typeLabel,
typeMicro,
warn,
warnInk,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'LiveAlarm'>;
const ACK_LABEL = 'Acknowledge';
const STOP_LABEL = 'Stop for everyone';
/**
* Live alarm takeover (DESIGN.md §8, `01-alarm-ux.md` §2, specs §9).
*
* Deliberately dependency-free at render time: everything shown comes from the
* event already in hand, never from a fetch, because the contract's target is
* under a second on a LAN. There is no offline strip and no error state that
* could hide the siren — the sound is bundled and local.
*
* Visual weight is intentional and audited (D-1): **Acknowledge** is the solid
* white primary because it is the safe, reversible action ("silence this
* device"); **Stop for everyone** is an outline because it is irreversible and
* affects other people. On a screaming phone the thumb finds the biggest white
* target first, and that must never be the one that ends the alarm everywhere.
*/
export default function LiveAlarmScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const { liveEvent, clearLiveEvent, adminAlerts, adminTokenFor, subscribedAlerts, updateAlertLocal } =
useAlarm();
const { username } = useAuth();
const alertId = params.alertId;
const event = liveEvent && liveEvent.alertId === alertId ? liveEvent : null;
const message = event?.message ?? params.message;
const alert =
adminAlerts.find(a => a.alertId === alertId) ?? subscribedAlerts.find(a => a.alertId === alertId);
const isOwner = !!adminAlerts.find(a => a.alertId === alertId);
const adminToken = adminTokenFor(alertId);
const [acknowledged, setAcknowledged] = useState(false);
const [volumeBlocked, setVolumeBlocked] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reduceMotion, setReduceMotion] = useState(false);
const [now, setNow] = useState(() => Date.now());
const shake = useRef(new Animated.Value(0)).current;
const fade = useRef(new Animated.Value(0)).current;
const serverTime = event?.serverTime ?? params.serverTime;
const triggeredAt = parseServerTime(serverTime);
/**
* Attribution. The alarm is always attributed to *a human*, never to "the
* system" — but the wire has no actor field (`alertView` in
* `Alarm/src/actions/alerts.go:131` and `realtime.AlarmFrame` carry none, and
* this service only ever holds the admin's bare UUID), so a subscriber cannot
* know the handle. Lead ruling 2026-10-18: when the handle is unavailable the
* line still attributes the alarm, just without a name —
* `The alert owner triggered the alarm`. The `@{handle}` form is used when the
* data genuinely exists (the owner's own view, or the day the service sends
* `owner`/`ownerUsername`), and is read opportunistically rather than assumed.
*/
const ownerHandle =
(alert as (api.Alert & { owner?: string; ownerUsername?: string }) | undefined)?.owner ||
(alert as (api.Alert & { ownerUsername?: string }) | undefined)?.ownerUsername ||
(isOwner ? username : null) ||
null;
const attribution = ownerHandle
? `@${ownerHandle} triggered the alarm`
: 'The alert owner triggered the alarm';
// Reduced motion: a 900ms stepped klaxon shake is exactly what this setting
// exists to suppress. Subscribe so a mid-alarm preference change is honoured.
useEffect(() => {
let mounted = true;
AccessibilityInfo.isReduceMotionEnabled()
.then(enabled => mounted && setReduceMotion(enabled))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
return () => {
mounted = false;
sub.remove();
};
}, []);
// The count-up ticks every second while the takeover is on screen.
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
Animated.timing(fade, { toValue: 1, duration: 220, useNativeDriver: true }).start();
if (reduceMotion) return;
// A stepped ±8° oscillation reads as a physical klaxon; a smooth full
// rotation reads as a loading spinner (spec §2 item 1).
const loop = Animated.loop(
Animated.timing(shake, {
toValue: 1,
duration: motionSiren,
easing: Easing.linear,
useNativeDriver: true,
}),
);
loop.start();
return () => {
loop.stop();
shake.setValue(0);
};
}, [fade, shake, reduceMotion]);
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
// refuses the alarm stream, the notification fallback carries it and the
// screen says so instead of pretending.
useEffect(() => {
let cancelled = false;
(async () => {
const res = await playAlarm(alertId, message ?? '', event?.autoStopSeconds ?? 0);
if (cancelled) return;
if (!res.ok && res.reason !== 'native-module-unavailable') setVolumeBlocked(true);
if (res.ok && res.volumeRaised === false) setVolumeBlocked(true);
})();
return () => {
cancelled = true;
stopAlarm();
};
}, [alertId, message, event?.autoStopSeconds]);
const leave = useCallback(() => {
clearLiveEvent();
if (navigation.canGoBack()) navigation.goBack();
else navigation.navigate('Home');
}, [clearLiveEvent, navigation]);
/**
* Acknowledge silences **this device only**. It must not leave the screen:
* the acknowledged state is the confirmation, the route to Stop has to stay
* reachable, and the alarm may still be live elsewhere (D-10).
*/
const ack = useCallback(async () => {
setAcknowledged(true);
try {
await stopAlarm();
} catch {
// Best effort: the notification fallback still applies.
}
// Once the native player is stopped this device is silent, so the live
// event no longer needs to drive a takeover — but the screen stays mounted
// in its acknowledged state until the user leaves it.
clearLiveEvent();
}, [clearLiveEvent]);
const stop = useCallback(async () => {
if (!adminToken) {
setError('Only the alert owner can stop the alarm on every device.');
return;
}
setBusy(true);
setError(null);
try {
await api.disarmAlert({ alertId, adminToken });
updateAlertLocal(alertId, { status: 'disarmed' });
await stopAlarm();
leave();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not stop the alarm.');
} finally {
setBusy(false);
}
}, [adminToken, alertId, leave, updateAlertLocal]);
// Hardware back and the back gesture are consumed: the only exits are
// Acknowledge and Stop alarm (spec, Cross-screen rules).
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, []);
// `steps(1, end)` as a square wave: a linear driver sampled at the halfway
// point jumps −8° → +8° with no interpolation, which is the mechanical klaxon
// jerk the spec asks for (a smooth oscillation reads as decoration).
const rotate = shake.interpolate({
inputRange: [0, 0.499, 0.5, 1],
outputRange: ['-8deg', '-8deg', '8deg', '8deg'],
});
const elapsed = relativeSince(serverTime, now);
const elapsedLabel = elapsed ? `on your device · ${elapsed}` : 'on your device';
const absoluteLabel = triggeredAt ? `Triggered at ${absoluteLocal(triggeredAt)}` : undefined;
return (
<Animated.View style={[styles.root, { opacity: fade }]}>
<View style={styles.center}>
<Animated.View style={reduceMotion ? undefined : { transform: [{ rotate }] }}>
<Siren size={48} color={onAlarm} />
</Animated.View>
<AppText style={styles.label}>ALARM</AppText>
{/* The alert name is the headline: at the moment of maximum stress this
is the one piece of information the user needs (D-5). */}
<AppText style={styles.name} numberOfLines={2}>
{alert?.name ?? alertId}
</AppText>
<AppText style={styles.owner}>{attribution}</AppText>
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
{elapsedLabel}
</AppText>
{message ? <AppText style={styles.message}>{message}</AppText> : null}
{acknowledged ? (
<AppText style={styles.ackNote} accessibilityLiveRegion="polite">
Acknowledged — this device is silent. The alarm may still be live elsewhere.
</AppText>
) : null}
</View>
<View style={styles.actions}>
{volumeBlocked ? (
<View style={styles.volumeStrip}>
<VolumeX size={16} color={warn} />
<View style={styles.volumeCopy}>
<AppText style={styles.volumeText}>
Volume is locked by your device — turn it up now
</AppText>
</View>
<Pressable
onPress={() => {
openSoundSettings();
}}
accessibilityRole="button"
accessibilityLabel="Open the system volume controls"
style={styles.raiseButton}
>
<AppText style={styles.raiseLabel}>Raise volume</AppText>
</Pressable>
</View>
) : null}
{!isAlarmNativeAvailable ? (
<AppText style={styles.note}>
No native alarm engine in this build — playing in-app only.
</AppText>
) : null}
{error ? (
<View style={styles.errorBox}>
<AppText style={{ ...typeLabel, color: onAlarm }}>{error}</AppText>
</View>
) : null}
{/* D-1: Acknowledge is the solid primary (safe, reversible). */}
<Pressable
onPress={ack}
disabled={acknowledged || busy}
accessibilityRole="button"
accessibilityLabel="Acknowledge the alarm on this device"
accessibilityHint="Silences this device only. The alarm keeps ringing on others."
style={[styles.ack, acknowledged || busy ? styles.disabled : null]}
>
<BellOff size={20} color={takeoverFlat} />
<AppText style={styles.ackLabel}>{acknowledged ? 'Acknowledged' : ACK_LABEL}</AppText>
</Pressable>
{/* Stop for everyone is owner-only and deliberately quieter than ACK. */}
{isOwner ? (
<Pressable
onPress={stop}
disabled={busy}
accessibilityRole="button"
accessibilityLabel="Stop the alarm on every device"
accessibilityHint="Ends the alarm for every subscribed device."
style={[styles.stop, busy ? styles.disabled : null]}
>
<OctagonX size={20} color={onAlarm} />
<AppText style={styles.stopLabel}>{busy ? 'Stopping…' : STOP_LABEL}</AppText>
</Pressable>
) : null}
<AppText style={styles.footer}>
{isOwner
? 'Acknowledge silences this device only · Stop ends it for everyone'
: 'Acknowledge silences this device · only the alert owner can stop it for everyone'}
</AppText>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
// Gradients must degrade to a flat fill; this is the documented flat value.
backgroundColor: takeoverFlat,
paddingHorizontal: space6,
paddingTop: space6,
paddingBottom: space6 + space2,
justifyContent: 'space-between',
},
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space3 },
label: { ...typeMicro, color: onAlarmDim, letterSpacing: 1.6 },
name: { ...typeDisplay, color: onAlarm, textAlign: 'center' },
owner: { ...typeHeading, color: onAlarm, textAlign: 'center' },
elapsed: { ...typeLabel, color: onAlarmDim },
message: { ...typeBodyStrong, color: onAlarm, textAlign: 'center' },
ackNote: { ...typeCaption, color: onAlarm, textAlign: 'center' },
actions: { gap: space3 },
volumeStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: space3,
borderWidth: 1,
borderColor: warn,
borderRadius: radiusFull,
paddingLeft: space4,
paddingRight: space2,
paddingVertical: space2,
minHeight: 44,
},
volumeCopy: { flex: 1 },
volumeText: { ...typeLabel, color: warn },
raiseButton: {
height: 36,
paddingHorizontal: space4,
borderRadius: radiusFull,
backgroundColor: warn,
alignItems: 'center',
justifyContent: 'center',
},
raiseLabel: { ...typeLabel, color: warnInk },
note: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
errorBox: {
borderWidth: 1,
borderColor: onAlarm,
borderRadius: radiusFull,
paddingHorizontal: space4,
paddingVertical: space3,
},
ack: {
height: 64,
borderRadius: radiusFull,
backgroundColor: onAlarm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
},
ackLabel: { ...typeBodyStrong, color: takeoverFlat },
stop: {
height: 56,
borderRadius: radiusFull,
borderWidth: 2,
borderColor: onAlarm,
backgroundColor: 'transparent',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: space2,
},
stopLabel: { ...typeBodyStrong, color: onAlarm },
disabled: { opacity: 0.4 },
footer: { ...typeCaption, color: onAlarmDim, textAlign: 'center' },
});
+149
View File
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { useAuth } from '../state/AuthContext';
import { useConnection } from '../state/ConnectionContext';
import {
AppText,
Button,
ErrorBanner,
Field,
OfflineStrip,
Screen,
TextInputBox,
} from '../components/ui';
import { space4, text, textMuted, textSecondary, typeCaption, typeLabel, typeTitle } from '../theme/tokens';
type Mode = 'signin' | 'register';
export default function LoginScreen() {
const { login, register } = useAuth();
const { online, resolved } = useConnection();
const [mode, setMode] = useState<Mode>('signin');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isRegister = mode === 'register';
const canSubmit = !!username.trim() && !!password && (!isRegister || !!email.trim()) && online && !busy;
const submit = async () => {
if (!canSubmit) return;
setBusy(true);
setError(null);
try {
if (isRegister) {
await register(username.trim(), password, email.trim());
} else {
await login(username.trim(), password);
}
} catch (e) {
setError(
e instanceof Error && e.message
? e.message
: isRegister
? 'Could not create the account. Check your details and try again.'
: 'Sign in failed. Check your username and password.',
);
} finally {
setBusy(false);
}
};
return (
<View style={{ flex: 1 }}>
{!online ? <OfflineStrip /> : null}
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Screen>
<View style={{ gap: space4, paddingTop: space4 }}>
<View style={{ gap: space4, marginBottom: space4 }}>
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
Max-volume alerts for your devices
</AppText>
<AppText style={{ ...typeLabel, color: textMuted }}>
{isRegister ? 'Create account' : 'Sign in'}
</AppText>
</View>
<Field label="Username" accessibilityLabel="Username">
<TextInputBox
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="Username"
placeholder="your username"
/>
</Field>
<Field label="Password" accessibilityLabel="Password, hidden characters">
<TextInputBox
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
accessibilityLabel="Password, hidden characters"
placeholder="••••••••"
/>
</Field>
{isRegister ? (
<Field label="Email" accessibilityLabel="Email">
<TextInputBox
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
accessibilityLabel="Email"
placeholder="[email protected]"
/>
</Field>
) : null}
{error ? <ErrorBanner message={error} /> : null}
<Button
label={
busy
? isRegister
? 'Creating account…'
: 'Signing in…'
: isRegister
? 'Create account'
: 'Sign in'
}
onPress={submit}
disabled={!canSubmit}
height={56}
/>
<Button
label={isRegister ? 'Have an account? Sign in' : 'New here? Create an account'}
variant="ghost"
height={44}
onPress={() => {
setError(null);
setMode(isRegister ? 'signin' : 'register');
}}
/>
{!online ? (
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
{resolved
? 'The alarm service did not answer. Check Settings → Connection.'
: 'Looking for the alarm service…'}
</AppText>
) : null}
</View>
</Screen>
</KeyboardAvoidingView>
</View>
);
}
+288
View File
@@ -0,0 +1,288 @@
import React, { useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Copy, Info, RefreshCw, SunMedium } from 'lucide-react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { QrCanvas } from '../components/QrCanvas';
import { AppText, Button, Card, Header } from '../components/ui';
import { useAlarm } from '../state/AlarmContext';
import * as api from '../services/alarmApi';
import { buildQrPayload, truncateToken } from '../lib/qrPayload';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
radiusLg,
radiusMd,
radiusXl,
space2,
space3,
space4,
space6,
stateAlarm,
surface,
surfaceRaised,
text,
textFaint,
textSecondary,
typeBody,
typeCaption,
typeCode,
typeHeading,
typeLabel,
typeMicro,
typeTitle,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'QrDisplay'>;
/**
* QR display. The payload is built locally from the stored subscribe token so
* the screen works with no network; the server-rendered PNG is only a fallback
* for the (unlikely) case where the local render fails.
*
* Brightness is **not** raised: there is no brightness dependency in the tree,
* so the hint tells the user to raise it themselves. The spec's "brightness is
* raised" claim was removed rather than shipped as a false affordance (D-11).
*/
export default function QrDisplayScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const alertId = params.alertId;
const { getAlert, subscribeTokenFor, adminTokenFor, updateAlertLocal } = useAlarm();
const alert = getAlert(alertId);
const adminToken = adminTokenFor(alertId);
const [notice, setNotice] = useState<string | null>(null);
const [rotatedToken, setRotatedToken] = useState<string | null>(null);
const [confirmRotate, setConfirmRotate] = useState(false);
const [rotating, setRotating] = useState(false);
const [error, setError] = useState<string | null>(null);
const storedToken = subscribeTokenFor(alertId);
const token = rotatedToken ?? storedToken;
const payload = useMemo(
() => (token ? buildQrPayload(alertId, token) : null),
[alertId, token],
);
const copy = () => {
if (!payload) return;
try {
Clipboard.setString(payload);
setNotice('Alert link copied.');
} catch {
setNotice('Could not copy — long-press the code below to select it.');
}
};
/**
* Rotating invalidates the QR/join path only: people who already joined stay
* subscribed (verified in `Alarm/src/actions/alerts.go` rotateSubscribeToken).
* Two-step confirm, same shape as the trigger, because it silently makes every
* printed code useless.
*/
const rotate = async () => {
if (!adminToken) {
setError('Only the alert owner can rotate the subscribe token.');
setConfirmRotate(false);
return;
}
setRotating(true);
setError(null);
try {
const res = await api.rotateSubscribeToken({ alertId, adminToken });
setRotatedToken(res.subscribeToken);
updateAlertLocal(alertId, { subscribeToken: res.subscribeToken, qrPayload: res.qrPayload });
setNotice('Token rotated. Codes you shared before now no longer work.');
setConfirmRotate(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not rotate the token.');
} finally {
setRotating(false);
}
};
if (!token) {
return (
<View style={styles.root}>
<Header title="QR code" onBack={() => navigation.goBack()} />
<View style={styles.body}>
<AppText style={{ ...typeHeading, color: text }}>No subscribe token here</AppText>
<AppText style={styles.para}>
This device does not hold the subscribe token for {alertId}, so it cannot show a code
that would join anyone. Open the alert from the device that created it.
</AppText>
</View>
</View>
);
}
return (
<View style={styles.root}>
<Header
title={alert?.name ?? 'Alert code'}
subtitle="Scan to subscribe this device"
onBack={() => navigation.goBack()}
/>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.tileWrap}>
<QrCanvas
value={payload ?? ''}
size={240}
accessibilityLabel={`QR code that subscribes a device to ${alert?.name ?? alertId}`}
/>
</View>
<View style={styles.hintRow}>
<SunMedium size={16} color={textSecondary} />
<AppText style={{ ...typeCaption, color: textSecondary, flex: 1 }}>
Turn your screen brightness up — this app cannot do it for you
</AppText>
</View>
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Or paste the code by hand</AppText>
<Pressable
onPress={copy}
accessibilityRole="button"
accessibilityLabel="Copy the alert link"
style={styles.codeBox}
>
<AppText style={{ ...typeCode, color: text, flex: 1 }} numberOfLines={2}>
{payload}
</AppText>
<Copy size={16} color={textFaint} />
</Pressable>
<AppText style={{ ...typeMicro, color: textFaint }}>
Subscribe token {truncateToken(token, 12)}
</AppText>
{notice ? (
<AppText style={{ ...typeCaption, color: focusRing }} accessibilityLiveRegion="polite">
{notice}
</AppText>
) : null}
{error ? (
<AppText style={{ ...typeCaption, color: stateAlarm }} accessibilityLiveRegion="polite">
{error}
</AppText>
) : null}
</View>
</Card>
{/* Rotation: revocability is one of the product's four promises. */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Rotate the token</AppText>
<AppText style={styles.para}>
Rotating makes every code you shared before stop working. Devices that already joined
stay subscribed — you would have to revoke those individually.
</AppText>
<Button
label={adminToken ? 'Rotate token' : 'Only the owner can rotate'}
variant="secondary"
icon={RefreshCw}
disabled={!adminToken || rotating}
onPress={() => setConfirmRotate(true)}
/>
</View>
</Card>
{/* Informational, not destructive: sharing a code is not a delete (D-24). */}
<View style={styles.infoBox}>
<View style={styles.infoRow}>
<Info size={18} color={textSecondary} />
<AppText style={{ ...typeLabel, color: text, flex: 1 }}>Keep this code private</AppText>
</View>
<AppText style={{ ...typeCaption, color: textSecondary, marginTop: space2 }}>
Anyone who scans it can subscribe a device to this alert. The owner token that controls
triggering is separate and is shown only once — it cannot be recovered.
</AppText>
</View>
<Button label="Done" variant="secondary" onPress={() => navigation.goBack()} />
</ScrollView>
<Modal
visible={confirmRotate}
transparent
animationType="fade"
onRequestClose={() => setConfirmRotate(false)}
statusBarTranslucent
>
<View style={styles.scrim}>
<View style={styles.sheet}>
<AppText style={{ ...typeTitle, color: text }}>Rotate the subscribe token?</AppText>
<AppText style={{ ...typeBody, color: textSecondary }}>
New devices can no longer join with the current QR. Devices already subscribed keep
working — remove them individually, or delete the alert to end it for everyone.
</AppText>
<Button
label={rotating ? 'Rotating…' : 'Rotate token'}
variant="alarm"
height={56}
disabled={rotating}
onPress={rotate}
/>
<Button
label="Cancel"
variant="secondary"
height={48}
onPress={() => setConfirmRotate(false)}
/>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
content: { padding: space4, paddingBottom: 48, gap: space4, alignItems: 'stretch' },
tileWrap: { alignItems: 'center', paddingVertical: space6 },
body: { padding: space4, gap: space3 },
para: { ...typeCaption, color: textSecondary },
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: space2,
paddingHorizontal: space2,
},
codeBox: {
flexDirection: 'row',
alignItems: 'center',
gap: space3,
backgroundColor: surfaceRaised,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
padding: space3,
minHeight: 48,
},
infoBox: {
borderWidth: 1,
borderColor: border,
backgroundColor: surface,
borderRadius: radiusLg,
padding: space4,
},
infoRow: { flexDirection: 'row', alignItems: 'center', gap: space2 },
scrim: {
flex: 1,
backgroundColor: 'rgba(11,13,16,0.72)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: surface,
borderTopLeftRadius: radiusXl,
borderTopRightRadius: radiusXl,
padding: space6,
gap: space4,
},
});
+369
View File
@@ -0,0 +1,369 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
Animated,
Easing,
Keyboard,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Camera,
useCameraDevice,
useCameraPermission,
useObjectOutput,
} from 'react-native-vision-camera';
import { ScanLine, X } from 'lucide-react-native';
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
import { useAlarm } from '../state/AlarmContext';
import { joinErrorMessage } from '../services/alarmApi';
import { parseQrPayload, parseManualToken, qrErrorMessage } from '../lib/qrPayload';
import { openAppSettings } from '../services/alarmNative';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
minTapTarget,
motionSiren,
radiusFull,
scrim,
stateIdle,
space2,
space3,
space4,
surface,
text,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeHeading,
typeLabel,
warn,
} from '../theme/tokens';
/** §7.5: the scan window is 280×280 with 3px corner brackets. */
const VIEWFINDER = 280;
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'Scanner'>;
/**
* QR scanner.
*
* The camera is `react-native-vision-camera` 5.x, which ships the object
* scanner in core (`useObjectOutput({types:['qr']})`) — there is no separate
* code-scanner plugin in v5. The manual paste field below is always available,
* including when the camera is denied or unavailable, because a subscribe token
* is a plain string.
*/
export default function ScannerScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const { joinAlert, deviceId } = useAlarm();
const { hasPermission, requestPermission, canRequestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manual, setManual] = useState('');
const [showManual, setShowManual] = useState(false);
const [joinedName, setJoinedName] = useState<string | null>(null);
const handled = useRef(false);
const [reduceMotion, setReduceMotion] = useState(false);
const sweepValue = useRef(new Animated.Value(0)).current;
useEffect(() => {
let mounted = true;
AccessibilityInfo.isReduceMotionEnabled()
.then(enabled => mounted && setReduceMotion(enabled))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
return () => {
mounted = false;
sub.remove();
};
}, []);
// The scan line is the affordance that says the scanner is working. Under
// reduce-motion it parks in the middle instead of sweeping.
useEffect(() => {
if (reduceMotion) {
sweepValue.setValue(0.5);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(sweepValue, {
toValue: 1,
duration: motionSiren,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sweepValue, {
toValue: 0,
duration: motionSiren,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]),
);
loop.start();
return () => loop.stop();
}, [reduceMotion, sweepValue]);
const targetAlertId = params?.alertId;
const complete = useCallback(
async (alertId: string, token: string) => {
if (busy || handled.current) return;
handled.current = true;
setBusy(true);
setError(null);
try {
const record = await joinAlert({
alertId,
subscribeToken: token,
deviceLabel: `Android ${deviceId?.slice(-4) ?? ''}`.trim(),
});
setJoinedName(record.name || alertId);
// Give the confirmation a beat, then return to the detail screen.
setTimeout(() => {
navigation.replace('AlertDetail', { alertId, role: 'subscriber' });
}, 700);
} catch (e) {
handled.current = false;
setError(joinErrorMessage(e));
} finally {
setBusy(false);
}
},
[busy, deviceId, joinAlert, navigation],
);
const onScanned = useCallback(
(objects: readonly { type: string; value?: string }[]) => {
for (const obj of objects) {
const value = typeof obj.value === 'string' ? obj.value : undefined;
if (obj.type !== 'qr' || !value) continue;
const parsed = parseQrPayload(value);
if (parsed.ok) {
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
setError('That code is for a different alert.');
continue;
}
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
} else {
setError(qrErrorMessage(parsed.error));
}
return;
}
},
[complete, targetAlertId],
);
// v5 ships the scanner in core: `useObjectOutput` is the code-scanner — there
// is no separate vision-camera-code-scanner plugin for 5.x.
const objectOutput = useObjectOutput({
types: ['qr'],
onObjectsScanned: onScanned,
});
const submitManual = async () => {
Keyboard.dismiss();
const trimmed = manual.trim();
if (!trimmed) return;
// Accept a full link, a scheme link, or a bare token when we know the alert.
const parsed = parseQrPayload(trimmed);
if (parsed.ok) {
await complete(parsed.payload.alertId, parsed.payload.subscribeToken);
return;
}
// A bare token can only be resolved when we arrived here from an alert.
if (targetAlertId) {
const byToken = parseManualToken(trimmed, targetAlertId);
if (byToken.ok) {
await complete(byToken.payload.alertId, byToken.payload.subscribeToken);
return;
}
}
setError(qrErrorMessage(parsed.error));
};
const sweep = sweepValue.interpolate({
inputRange: [0, 1],
outputRange: [-VIEWFINDER / 2, VIEWFINDER / 2],
});
return (
<View style={styles.root}>
<Header
title="Scan alert code"
subtitle="Point the camera at the QR code the owner shows"
onBack={() => navigation.goBack()}
right={
<Pressable
onPress={() => navigation.goBack()}
accessibilityRole="button"
accessibilityLabel="Close scanner"
style={styles.iconButton}
>
<X size={20} color={textMuted} />
</Pressable>
}
/>
<View style={styles.cameraWrap}>
{hasPermission && device ? (
<Camera
style={StyleSheet.absoluteFill}
device={device}
isActive={!joinedName}
outputs={[objectOutput]}
/>
) : (
<View style={styles.permission}>
<ScanLine size={40} color={textMuted} />
<AppText style={{ ...typeHeading, color: text, marginTop: space3 }}>
Camera access is off
</AppText>
<AppText style={styles.para}>
The camera is only used to read alert codes. You can also paste the code by hand.
</AppText>
{canRequestPermission ? (
<Button label="Allow camera" onPress={requestPermission} />
) : (
<>
<AppText style={styles.para}>
Camera permission was denied. Turn it on in Android Settings → Apps → Alarm →
Permissions.
</AppText>
<Button label="Open settings" variant="secondary" onPress={() => { openAppSettings(); }} />
</>
)}
<Button label="Enter code by hand" variant="outlineWhite" onPress={() => setShowManual(true)} />
</View>
)}
{hasPermission && !joinedName ? (
<View pointerEvents="none" style={styles.reticleWrap}>
<View style={styles.viewfinder}>
{/* Four 3px corner brackets: "aim inside this frame" without a
box that fights the camera image (DESIGN.md §7.5). */}
<View style={[styles.bracket, styles.bracketTL]} />
<View style={[styles.bracket, styles.bracketTR]} />
<View style={[styles.bracket, styles.bracketBL]} />
<View style={[styles.bracket, styles.bracketBR]} />
<Animated.View
style={[
styles.scanLine,
reduceMotion ? styles.scanLineRest : { transform: [{ translateY: sweep }] },
]}
/>
</View>
<AppText style={styles.reticleHint}>Keep the whole code inside the frame</AppText>
</View>
) : null}
{joinedName ? (
<View style={styles.joined}>
<AppText style={{ ...typeHeading, color: text }}>Subscribed</AppText>
<AppText style={styles.para}>This device will ring for {joinedName}.</AppText>
</View>
) : null}
</View>
<View style={styles.sheet}>
{error ? <ErrorBanner message={error} /> : null}
{showManual || !hasPermission || !device ? (
<View style={{ gap: space3 }}>
<Field label="Alert code or link">
<TextInputBox
value={manual}
onChangeText={setManual}
autoCapitalize="none"
autoCorrect={false}
placeholder="https://alarm.dcntrlized.org/a/…#token"
accessibilityLabel="Alert code or link"
/>
</Field>
<Button
label={busy ? 'Subscribing…' : 'Subscribe this device'}
onPress={submitManual}
disabled={busy || !manual.trim()}
/>
</View>
) : (
<Button label="Enter code by hand" variant="secondary" onPress={() => setShowManual(true)} />
)}
{targetAlertId ? (
<AppText style={styles.target}>Subscribing to {targetAlertId}</AppText>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
cameraWrap: { flex: 1, backgroundColor: '#000000', justifyContent: 'center' },
permission: { padding: space4, alignItems: 'center', gap: space2 },
para: { ...typeCaption, color: textSecondary, textAlign: 'center', marginBottom: space2 },
reticleWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
viewfinder: { width: VIEWFINDER, height: VIEWFINDER, justifyContent: 'center' },
bracket: {
position: 'absolute',
width: 40,
height: 40,
borderColor: stateIdle,
borderWidth: 3,
},
bracketTL: { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 },
bracketTR: { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 },
bracketBL: { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 },
bracketBR: { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 },
scanLine: {
height: 2,
marginHorizontal: 8,
backgroundColor: focusRing,
},
scanLineRest: { position: 'absolute', top: '50%', left: 0, right: 0 },
reticleHint: {
...typeCaption,
color: text,
marginTop: space3,
backgroundColor: scrim,
paddingHorizontal: space3,
paddingVertical: space2,
borderRadius: radiusFull,
},
joined: { alignItems: 'center', gap: space2, backgroundColor: scrim, padding: space4 },
sheet: {
padding: space4,
gap: space3,
backgroundColor: surface,
borderTopWidth: 1,
borderTopColor: border,
},
target: { ...typeLabel, color: textMuted, textAlign: 'center' },
manualHint: { ...typeBodyStrong, color: warn },
cameraIcon: { ...typeLabel, color: text },
});
+402
View File
@@ -0,0 +1,402 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Info, RefreshCw, Server, Smartphone, Wifi, WifiOff } from 'lucide-react-native';
import { useAuth } from '../state/AuthContext';
import { useAlarm } from '../state/AlarmContext';
import { useConnection } from '../state/ConnectionContext';
import {
getPermissions,
isAlarmNativeAvailable,
openExactAlarmSettings,
openFullScreenIntentSettings,
requestPermission,
startForegroundService,
stopForegroundService,
type AlarmPermissions,
} from '../services/alarmNative';
import { alarmSocketUrl } from '../services/connection';
import { AppText, Button, Card, Divider, HealthIcon, Header, OfflineStrip } from '../components/ui';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
minTapTarget,
radiusMd,
space2,
space3,
space4,
stateDisarmed,
surfaceRaised,
text,
textFaint,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeCode,
typeHeading,
typeLabel,
typeMicro,
warn,
} from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
const HEALTH_COPY: Record<keyof AlarmPermissions, { title: string; ok: string; bad: string }> = {
notifications: {
title: 'Notifications',
ok: 'Allowed — alerts can post a high-priority notification.',
bad: 'Not allowed — a trigger cannot notify you while the app is closed.',
},
fullScreenIntent: {
title: 'Full-screen alarm',
ok: 'Allowed — the takeover can cover the lock screen.',
bad: 'Not allowed — the alarm will only show as a notification when locked.',
},
exactAlarm: {
title: 'Exact alarms',
ok: 'Allowed — auto-stop and scheduled work fire on time.',
bad: 'Not allowed — Android may delay auto-stop.',
},
batteryUnrestricted: {
title: 'Battery optimisation',
ok: 'Unrestricted — the armed socket keeps running in the background.',
bad: 'Optimised — Android may freeze the app and drop the armed socket.',
},
canForceVolume: {
title: 'Alarm volume',
ok: 'The alarm stream can be raised to maximum automatically.',
bad: 'The OS blocks forced volume — you will get a notification instead.',
},
};
export default function SettingsScreen() {
const navigation = useNavigation<Nav>();
const { username, email, logout } = useAuth();
const { resolved, settings, updateSettings, reprobe, probing, online, isRelease } = useConnection();
const { activeAlertId, delivery, socketState, reconnectAttempts, subscribedAlerts } = useAlarm();
const [perms, setPerms] = useState<AlarmPermissions | null>(null);
const [serviceRunning, setServiceRunning] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const refreshPerms = useCallback(async () => {
setPerms(await getPermissions());
}, []);
useEffect(() => {
refreshPerms();
}, [refreshPerms]);
const activeAlert = subscribedAlerts.find(a => a.alertId === activeAlertId) ?? null;
const wsUrl = resolved && activeAlert
? alarmSocketUrl(resolved, activeAlert.alertId, activeAlert.subscribeToken ?? '')
: null;
const toggleService = async () => {
if (!activeAlert || !wsUrl) {
setNotice('Subscribe to an alert first — the foreground service holds that alert’s socket.');
return;
}
if (serviceRunning) {
await stopForegroundService();
setServiceRunning(false);
setNotice('Foreground service stopped. The socket only lives while the app is open.');
} else {
const res = await startForegroundService(activeAlert.alertId, activeAlert.subscribeToken ?? '', wsUrl);
setServiceRunning(res.ok);
setNotice(
res.ok
? 'Foreground service running — the armed socket survives backgrounding.'
: `Could not start the service (${res.reason ?? 'unknown'}).`,
);
}
};
return (
<View style={styles.root}>
{!online ? <OfflineStrip /> : null}
<Header title="Settings & health" onBack={() => navigation.goBack()} />
<ScrollView contentContainerStyle={styles.content}>
{/* ── Delivery ─────────────────────────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Live delivery</AppText>
<Row
icon={delivery === 'socket' ? Wifi : WifiOff}
tint={delivery === 'socket' ? stateDisarmed : warn}
title={
delivery === 'socket'
? `Connected (${socketState})`
: 'Unavailable in proxy mode'
}
detail={
delivery === 'socket'
? 'Triggers arrive over the service WebSocket; the target is under a second on a LAN.'
: 'Maestro has no service WebSocket passthrough, so status is polled every 5 seconds while the app is open and every 30 seconds in the background.'
}
/>
{delivery === 'socket' && reconnectAttempts > 0 ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Reconnect attempt {reconnectAttempts} — backing off up to 30s.
</AppText>
) : null}
<Row
icon={Smartphone}
tint={isAlarmNativeAvailable ? stateDisarmed : warn}
title={isAlarmNativeAvailable ? 'Android alarm engine present' : 'Android alarm engine unavailable'}
detail={
isAlarmNativeAvailable
? 'Max-volume STREAM_ALARM playback, vibration and the lock-screen takeover are wired.'
: 'This build has no native alarm module (expected off-Android); alerts fall back to an in-app sound.'
}
/>
<Button
label={serviceRunning ? 'Stop foreground service' : 'Start foreground service'}
variant="secondary"
onPress={toggleService}
/>
{notice ? (
<AppText style={{ ...typeCaption, color: focusRing }} accessibilityLiveRegion="polite">
{notice}
</AppText>
) : null}
</View>
</Card>
{/* ── Permission health checklist ──────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<View style={styles.headRow}>
<AppText style={{ ...typeHeading, color: text }}>Permissions</AppText>
<Pressable
onPress={refreshPerms}
accessibilityRole="button"
accessibilityLabel="Re-check permissions"
style={styles.iconButton}
>
<RefreshCw size={18} color={textMuted} />
</Pressable>
</View>
{perms ? (
<>
<HealthRow
ok={perms.notifications}
onFix={() => requestPermission('notifications').then(setPerms)}
okText={HEALTH_COPY.notifications.ok}
title={HEALTH_COPY.notifications.title}
bad={HEALTH_COPY.notifications.bad}
/>
<HealthRow
ok={perms.fullScreenIntent}
onFix={() =>
openFullScreenIntentSettings().then(() =>
requestPermission('fullScreenIntent').then(setPerms),
)
}
okText={HEALTH_COPY.fullScreenIntent.ok}
title={HEALTH_COPY.fullScreenIntent.title}
bad={HEALTH_COPY.fullScreenIntent.bad}
/>
<HealthRow
ok={perms.exactAlarm}
onFix={() =>
openExactAlarmSettings().then(() => requestPermission('exactAlarm').then(setPerms))
}
okText={HEALTH_COPY.exactAlarm.ok}
title={HEALTH_COPY.exactAlarm.title}
bad={HEALTH_COPY.exactAlarm.bad}
/>
<HealthRow
ok={perms.batteryUnrestricted}
onFix={() => requestPermission('battery').then(setPerms)}
okText={HEALTH_COPY.batteryUnrestricted.ok}
title={HEALTH_COPY.batteryUnrestricted.title}
bad={HEALTH_COPY.batteryUnrestricted.bad}
/>
<HealthRow
ok={perms.canForceVolume}
onFix={undefined}
okText={HEALTH_COPY.canForceVolume.ok}
title={HEALTH_COPY.canForceVolume.title}
bad={HEALTH_COPY.canForceVolume.bad}
/>
</>
) : (
<AppText style={{ ...typeCaption, color: textSecondary }}>
Reading permission state…
</AppText>
)}
</View>
</Card>
{/* ── Connection ───────────────────────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Connection</AppText>
<Row
icon={Server}
tint={online ? stateDisarmed : warn}
title={online ? `Reachable — ${settings.mode} mode` : 'No host answered'}
detail={resolved ? resolved.baseUrl : 'Run a probe to look for the service.'}
/>
<AppText style={styles.code}>{wsUrl ?? 'ws:// — no alert selected'}</AppText>
<View style={styles.modeRow}>
{(['maestro', 'direct'] as const).map(mode => (
<Pressable
key={mode}
onPress={() => updateSettings({ mode })}
accessibilityRole="radio"
accessibilityState={{ selected: settings.mode === mode }}
accessibilityLabel={mode === 'maestro' ? 'Maestro proxy mode' : 'Direct service mode'}
style={[styles.modeChip, settings.mode === mode ? styles.modeChipActive : null]}
>
<AppText
style={{
...typeLabel,
color: settings.mode === mode ? focusRing : textMuted,
}}
>
{mode === 'maestro' ? 'Maestro proxy' : 'Direct'}
</AppText>
</Pressable>
))}
</View>
<AppText style={{ ...typeMicro, color: textFaint }}>
Direct mode is the only mode with live WebSocket delivery. Proxy mode polls.
</AppText>
<Button label={probing ? 'Probing…' : 'Re-run connection probe'} variant="secondary" onPress={reprobe} />
{!isRelease ? (
<AppText style={{ ...typeMicro, color: textFaint }}>
Dev build · host {settings.host || '(auto)'} : {settings.port || '(default)'}
</AppText>
) : null}
</View>
</Card>
{/* ── Account ──────────────────────────────────────────────────── */}
<Card>
<View style={{ gap: space3 }}>
<AppText style={{ ...typeHeading, color: text }}>Account</AppText>
<Row
icon={Info}
tint={textMuted}
title={username ?? 'Not signed in'}
detail={email || 'Signed in on this instance'}
/>
<Button label="Sign out" variant="secondary" onPress={logout} />
</View>
</Card>
<Divider />
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
Alarm client · Dcntrlized · AGPL-3.0
</AppText>
</ScrollView>
</View>
);
}
function Row({
icon: Icon,
tint,
title,
detail,
}: {
icon: typeof Wifi;
tint: string;
title: string;
detail: string;
}) {
return (
<View style={styles.row}>
<Icon size={20} color={tint} />
<View style={{ flex: 1, gap: 2 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>{title}</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>{detail}</AppText>
</View>
</View>
);
}
function HealthRow({
ok,
title,
okText,
bad,
onFix,
}: {
ok: boolean;
title: string;
okText: string;
bad: string;
onFix?: () => void;
}) {
return (
<View style={styles.healthRow}>
<HealthIcon status={ok ? 'ok' : 'warn'} />
<View style={{ flex: 1, gap: 2 }}>
<AppText style={{ ...typeLabel, color: text }}>{title}</AppText>
<AppText style={{ ...typeCaption, color: ok ? textSecondary : warn }}>
{ok ? okText : bad}
</AppText>
</View>
{!ok && onFix ? (
<Pressable
onPress={onFix}
accessibilityRole="button"
accessibilityLabel={`Fix ${title}`}
style={styles.fixButton}
>
<AppText style={{ ...typeLabel, color: focusRing }}>Fix</AppText>
</Pressable>
) : null}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
content: { padding: space4, paddingBottom: 48, gap: space4 },
headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
row: { flexDirection: 'row', alignItems: 'flex-start', gap: space3 },
healthRow: { flexDirection: 'row', alignItems: 'center', gap: space3 },
fixButton: {
minHeight: 40,
paddingHorizontal: space3,
justifyContent: 'center',
borderRadius: radiusMd,
borderWidth: 1,
borderColor: focusRing,
},
code: {
...typeCode,
color: textMuted,
backgroundColor: surfaceRaised,
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
padding: space3,
},
modeRow: { flexDirection: 'row', gap: space2 },
modeChip: {
flex: 1,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radiusMd,
borderWidth: 1,
borderColor: border,
backgroundColor: surfaceRaised,
},
modeChipActive: { borderColor: focusRing },
});