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