init
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user