308 lines
8.3 KiB
TypeScript
308 lines
8.3 KiB
TypeScript
import { useState, useRef, useCallback } from 'react'
|
|
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { CameraView, useCameraPermissions } from 'expo-camera'
|
|
import { useRouter } from 'expo-router'
|
|
import { ChevronLeft } from 'lucide-react-native'
|
|
import { decodePairingUrl } from '../src/transport/pairing'
|
|
import { connect } from '../src/transport/rpc-client'
|
|
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
|
import type { PairingOffer } from '../src/transport/types'
|
|
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
|
|
|
function Step({ number, text }: { number: number; text: string }) {
|
|
return (
|
|
<View style={styles.step}>
|
|
<View style={styles.stepBadge}>
|
|
<Text style={styles.stepNumber}>{number}</Text>
|
|
</View>
|
|
<Text style={styles.stepText}>{text}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export default function PairScanScreen() {
|
|
const router = useRouter()
|
|
const insets = useSafeAreaInsets()
|
|
const [permission, requestPermission] = useCameraPermissions()
|
|
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
|
const [errorMessage, setErrorMessage] = useState('')
|
|
const processingRef = useRef(false)
|
|
|
|
const handleBarCodeScanned = useCallback(
|
|
({ data }: { data: string }) => {
|
|
if (processingRef.current) return
|
|
processingRef.current = true
|
|
|
|
const offer = decodePairingUrl(data)
|
|
if (!offer) {
|
|
setStatus('error')
|
|
setErrorMessage('Not a valid Orca QR code')
|
|
processingRef.current = false
|
|
return
|
|
}
|
|
|
|
void testAndSave(offer)
|
|
},
|
|
[router]
|
|
)
|
|
|
|
async function testAndSave(offer: PairingOffer) {
|
|
setStatus('connecting')
|
|
let client: ReturnType<typeof connect> | null = null
|
|
|
|
try {
|
|
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
|
|
const response = await client.sendRequest('status.get')
|
|
client.close()
|
|
client = null
|
|
|
|
if (!response.ok) {
|
|
if (response.error.code === 'unauthorized') {
|
|
setStatus('error')
|
|
setErrorMessage('Authentication failed — token may be expired')
|
|
processingRef.current = false
|
|
return
|
|
}
|
|
setStatus('error')
|
|
setErrorMessage(`Server error: ${response.error.message}`)
|
|
processingRef.current = false
|
|
return
|
|
}
|
|
|
|
const hostId = `host-${Date.now()}`
|
|
const hostName = await getNextHostName()
|
|
|
|
await saveHost({
|
|
id: hostId,
|
|
name: hostName,
|
|
endpoint: offer.endpoint,
|
|
deviceToken: offer.deviceToken,
|
|
publicKeyB64: offer.publicKeyB64,
|
|
lastConnected: Date.now()
|
|
})
|
|
|
|
router.replace(`/h/${hostId}`)
|
|
} catch {
|
|
setStatus('error')
|
|
setErrorMessage('Cannot connect — check that your computer is on the same network')
|
|
processingRef.current = false
|
|
} finally {
|
|
client?.close()
|
|
}
|
|
}
|
|
|
|
function retry() {
|
|
setStatus('scanning')
|
|
setErrorMessage('')
|
|
processingRef.current = false
|
|
}
|
|
|
|
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
|
|
|
if (!permission) {
|
|
return (
|
|
<View style={[styles.container, containerPadding]}>
|
|
<ActivityIndicator color={colors.textSecondary} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
if (!permission.granted) {
|
|
return (
|
|
<View style={[styles.container, containerPadding]}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<ChevronLeft size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
<View style={styles.centered}>
|
|
<Text style={styles.title}>Camera Permission</Text>
|
|
<Text style={styles.subtitle}>
|
|
Orca needs camera access to scan the pairing QR code from your desktop.
|
|
</Text>
|
|
<Pressable style={styles.primaryButton} onPress={requestPermission}>
|
|
<Text style={styles.primaryButtonText}>Grant Camera Access</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.container, containerPadding]}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<ChevronLeft size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
|
|
<View style={styles.steps}>
|
|
<Step number={1} text="Open Orca on your computer" />
|
|
<Step number={2} text="Go to Settings → Mobile" />
|
|
<Step number={3} text="Scan the QR code" />
|
|
</View>
|
|
|
|
{status === 'scanning' && (
|
|
<View style={styles.cameraWrap}>
|
|
<CameraView
|
|
style={styles.camera}
|
|
facing="back"
|
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
|
onBarcodeScanned={handleBarCodeScanned}
|
|
/>
|
|
<View style={styles.reticle} pointerEvents="none">
|
|
<View style={[styles.corner, styles.cornerTL]} />
|
|
<View style={[styles.corner, styles.cornerTR]} />
|
|
<View style={[styles.corner, styles.cornerBL]} />
|
|
<View style={[styles.corner, styles.cornerBR]} />
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{status === 'connecting' && (
|
|
<View style={styles.centered}>
|
|
<ActivityIndicator size="large" color={colors.textSecondary} />
|
|
<Text style={styles.connectingText}>Connecting…</Text>
|
|
</View>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
<Pressable style={styles.primaryButton} onPress={retry}>
|
|
<Text style={styles.primaryButtonText}>Try Again</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgBase,
|
|
padding: spacing.lg
|
|
},
|
|
backButton: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: spacing.sm
|
|
},
|
|
steps: {
|
|
gap: spacing.sm,
|
|
marginBottom: spacing.lg,
|
|
marginLeft: 7
|
|
},
|
|
step: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
stepBadge: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.bgRaised,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
stepNumber: {
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
color: colors.textSecondary
|
|
},
|
|
stepText: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textSecondary
|
|
},
|
|
cameraWrap: {
|
|
flex: 1,
|
|
borderRadius: radii.camera,
|
|
overflow: 'hidden'
|
|
},
|
|
camera: {
|
|
...StyleSheet.absoluteFillObject
|
|
},
|
|
reticle: {
|
|
...StyleSheet.absoluteFillObject,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
corner: {
|
|
position: 'absolute',
|
|
width: 28,
|
|
height: 28,
|
|
borderColor: 'rgba(255,255,255,0.7)'
|
|
},
|
|
cornerTL: {
|
|
top: '30%',
|
|
left: '20%',
|
|
borderTopWidth: 2.5,
|
|
borderLeftWidth: 2.5,
|
|
borderTopLeftRadius: 6
|
|
},
|
|
cornerTR: {
|
|
top: '30%',
|
|
right: '20%',
|
|
borderTopWidth: 2.5,
|
|
borderRightWidth: 2.5,
|
|
borderTopRightRadius: 6
|
|
},
|
|
cornerBL: {
|
|
bottom: '30%',
|
|
left: '20%',
|
|
borderBottomWidth: 2.5,
|
|
borderLeftWidth: 2.5,
|
|
borderBottomLeftRadius: 6
|
|
},
|
|
cornerBR: {
|
|
bottom: '30%',
|
|
right: '20%',
|
|
borderBottomWidth: 2.5,
|
|
borderRightWidth: 2.5,
|
|
borderBottomRightRadius: 6
|
|
},
|
|
centered: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
title: {
|
|
fontSize: typography.titleSize,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary,
|
|
marginBottom: spacing.sm
|
|
},
|
|
subtitle: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textSecondary,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.xl,
|
|
lineHeight: 20
|
|
},
|
|
connectingText: {
|
|
color: colors.textSecondary,
|
|
fontSize: typography.bodySize,
|
|
marginTop: spacing.lg
|
|
},
|
|
errorText: {
|
|
color: colors.statusRed,
|
|
fontSize: typography.bodySize,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.xl,
|
|
lineHeight: 20
|
|
},
|
|
primaryButton: {
|
|
backgroundColor: colors.textPrimary,
|
|
paddingHorizontal: spacing.xl,
|
|
paddingVertical: spacing.sm + 2,
|
|
borderRadius: radii.button
|
|
},
|
|
primaryButtonText: {
|
|
color: colors.bgBase,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '600'
|
|
}
|
|
})
|