This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+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 },
});