diff --git a/package-lock.json b/package-lock.json index 642dac1..1e10d9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@boof2015/astra-signal": "^0.3.0", + "@boof2015/astra-signal": "0.3.0", "@boof2015/xlrc": "^0.2.1", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", diff --git a/package.json b/package.json index c69d9ec..8973fc7 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "main": "index.js", "version": "0.1.0", "dependencies": { - "@boof2015/astra-signal": "^0.3.0", + "@boof2015/astra-signal": "0.3.0", "@boof2015/xlrc": "^0.2.1", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index b781da8..8970656 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -124,9 +124,11 @@ function formatHomeClockDate(date: Date): string { function HomeMasthead({ mode, onSearch, + onScan, }: { mode: HomeGreetingTextMode; onSearch: () => void; + onScan: () => void; }) { const styles = useStyles(); const colors = useColors(); @@ -189,8 +191,29 @@ function HomeMasthead({ ); + const scanButton = ( + + + + ); + + const utilityButtons = ( + + {scanButton} + {searchButton} + + ); + if (mode === 'off') { - return {searchButton}; + return {utilityButtons}; } const primary = mode === 'clock' ? formatHomeClockTime(clockNow) : greeting.primary; @@ -208,7 +231,7 @@ function HomeMasthead({ ) : null} - {searchButton} + {utilityButtons} ); } @@ -600,6 +623,7 @@ export default function HomeScreen() { }; const openSearch = () => openQuickSearch(); + const openSignalScanner = () => router.push('/signal/scan' as never); return ( @@ -611,7 +635,11 @@ export default function HomeScreen() { onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} > - + @@ -741,6 +769,11 @@ const useStyles = createThemedStyles((colors) => ({ justifyContent: 'center', gap: spacing.xs, }, + mastheadActions: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, mastheadPrimary: { fontSize: 28, lineHeight: 32, diff --git a/src/app/signal/index.tsx b/src/app/signal/index.tsx index 9a037b2..d1ddf11 100644 --- a/src/app/signal/index.tsx +++ b/src/app/signal/index.tsx @@ -6,8 +6,10 @@ import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-syst import * as Sharing from 'expo-sharing'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; +import { NowPlayingWash } from '@/components/NowPlayingWash'; import { SignalCode, type SignalCodeHandle } from '@/components/signal/SignalCode'; import { encodeTrackSignalLink, signalLayoutFromTrack } from '@/audio/signalShare'; +import { playerBackdropArtworkSource } from '@/library/artwork'; import { usePlayerStore } from '@/stores/playerStore'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; @@ -27,6 +29,7 @@ export default function SignalScreen() { const codeRef = useRef(null); const { width: screenWidth } = useWindowDimensions(); const layout = useMemo(() => (track ? signalLayoutFromTrack(track) : null), [track]); + const artworkWash = playerBackdropArtworkSource(track); const availableWidth = Math.max(1, screenWidth - spacing.lg * 4); const targetWidth = layout?.tier === 'small' ? 280 : layout?.tier === 'medium' ? 340 : availableWidth; const codeWidth = Math.min(targetWidth, availableWidth); @@ -52,6 +55,10 @@ export default function SignalScreen() { return ( + router.back()} hitSlop={8}> @@ -59,9 +66,6 @@ export default function SignalScreen() { Back - router.navigate('/signal/scan' as never)} hitSlop={8}> - - @@ -77,6 +81,18 @@ export default function SignalScreen() { ) : ( + + + NOW PLAYING + + + {track.title} + + + {track.artist} + + + - - {track.title} - - - {track.artist} + + + Keep the full white card visible when another phone scans it. void shareImage()}> - Share Signal + Share image void shareLink()}> @@ -126,9 +140,6 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', gap: 2, }, - scanBtn: { - padding: spacing.xs, - }, heading: { marginBottom: spacing.lg, }, @@ -141,18 +152,33 @@ const useStyles = createThemedStyles((colors) => ({ body: { alignItems: 'center', }, + trackMeta: { + width: '100%', + alignItems: 'center', + gap: spacing.xs, + marginBottom: spacing.lg, + }, codeCard: { padding: spacing.lg, borderRadius: radius.lg, backgroundColor: CODE_BG, marginBottom: spacing.lg, overflow: 'hidden', + elevation: 6, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.22, + shadowRadius: 14, }, trackTitle: { - marginTop: spacing.xs, + textAlign: 'center', + }, + hint: { + maxWidth: 320, + textAlign: 'center', }, actions: { - flexDirection: 'row', + width: '100%', gap: spacing.md, marginTop: spacing.xl, }, @@ -160,7 +186,7 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - minHeight: 44, + minHeight: 48, borderRadius: radius.sm, backgroundColor: colors.accent, paddingHorizontal: spacing.lg, @@ -170,7 +196,7 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - minHeight: 44, + minHeight: 48, borderRadius: radius.sm, backgroundColor: colors.bgSecondary, borderWidth: StyleSheet.hairlineWidth, diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx index dab36da..d151cbd 100644 --- a/src/app/signal/scan.tsx +++ b/src/app/signal/scan.tsx @@ -1,5 +1,5 @@ -import { useRef, useState } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as DocumentPicker from 'expo-document-picker'; import { Ionicons } from '@expo/vector-icons'; @@ -7,32 +7,64 @@ import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; import { SignalResultCard } from '@/components/signal/SignalResultCard'; +import { + SignalScanTransition, + type SignalScanPhase, +} from '@/components/signal/SignalScanTransition'; import { decodeSignalFromUri } from '@/audio/signalDecodeImage'; import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry'; +import { usePlayerStore } from '@/stores/playerStore'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import type { SignalPayload } from '@boof2015/astra-signal'; +const MIN_READING_MS = 600; +const FAILURE_RETURN_MS = 480; + export default function SignalScanScreen() { const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); const router = useRouter(); + const currentTrack = usePlayerStore((state) => state.currentTrack); const cameraRef = useRef(null); const [permission, requestPermission] = useCameraPermissions(); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); + const [phase, setPhase] = useState('idle'); const [error, setError] = useState(null); const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 }); + const readingStartedAt = useRef(0); - const runDecode = async (uri: string, useGuideCrop = false) => { + useEffect(() => { + if (phase !== 'failure') return; + const timeout = setTimeout(() => setPhase('idle'), FAILURE_RETURN_MS); + return () => clearTimeout(timeout); + }, [phase]); + + const beginReading = () => { + readingStartedAt.current = Date.now(); setBusy(true); setError(null); + setPhase('reading'); + }; + + const finishReadingBeat = async () => { + const remaining = MIN_READING_MS - (Date.now() - readingStartedAt.current); + if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining)); + }; + + const runDecode = async (uri: string, useGuideCrop = false) => { try { - setResult(await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined)); + const payload = await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined); + await finishReadingBeat(); + setResult(payload); + setPhase('success'); } catch { + await finishReadingBeat(); setError("Couldn't read Signal. Line the code up in the frame, hold steady, and try again."); + setPhase('failure'); } finally { setBusy(false); } @@ -40,102 +72,163 @@ export default function SignalScanScreen() { const capture = async () => { if (busy) return; - const photo = await cameraRef.current?.takePictureAsync({ quality: 1 }); - if (photo?.uri) await runDecode(photo.uri, true); + beginReading(); + try { + const photo = await cameraRef.current?.takePictureAsync({ quality: 1 }); + if (photo?.uri) { + await runDecode(photo.uri, true); + } else { + await finishReadingBeat(); + setError("Couldn't capture that image. Hold steady and try again."); + setPhase('failure'); + setBusy(false); + } + } catch { + await finishReadingBeat(); + setError("Couldn't capture that image. Hold steady and try again."); + setPhase('failure'); + setBusy(false); + } }; const pickImage = async () => { if (busy) return; const picked = await DocumentPicker.getDocumentAsync({ type: 'image/*', copyToCacheDirectory: true }); const uri = picked.assets?.[0]?.uri; - if (uri) await runDecode(uri); + if (uri) { + beginReading(); + await runDecode(uri); + } }; + const scanAnother = () => { + setResult(null); + setError(null); + setPhase('idle'); + }; + const standaloneResult = result !== null && !permission?.granted; + return ( router.back()} hitSlop={8}> - Signal + Back - Scan the Signal + Scan a Signal - - {!permission ? ( - - ) : !permission.granted ? ( - - - Camera access is needed to scan a Signal. - void requestPermission()}> - - Allow camera - - - void pickImage()}> - - Or pick a Signal image - - + {standaloneResult ? ( + + + + + + + Scan another + + + router.back()}> + + Done + + + ) : ( - - setPreviewSize(event.nativeEvent.layout)} - /> - - - - - - - {busy ? ( - - - - Reading signal… + <> + + Keep the full white Signal card visible and hold steady. + + + {currentTrack ? ( + router.push('/signal' as never)} + accessibilityRole="button" + accessibilityLabel={`View the Signal for ${currentTrack.title} by ${currentTrack.artist}`} + > + + + + + + NOW PLAYING + + + {currentTrack.title} · {currentTrack.artist} + + + + + ) : null} + + {!permission ? ( + + ) : !permission.granted ? ( + + + Camera access is needed to scan a Signal. + void requestPermission()}> + + Allow camera + + + void pickImage()}> + + Or pick a Signal image + + + + ) : ( + + setPreviewSize(event.nativeEvent.layout)} + /> + + + + + + + + void pickImage()} hitSlop={8}> + + + void capture()} hitSlop={8}> + + + + + router.back()} + /> + + )} + + {error && phase === 'idle' ? ( + + + + {error} ) : null} - - void pickImage()} hitSlop={8}> - - - void capture()} hitSlop={8}> - - - - - + )} - - {error ? ( - - - - {error} - - - ) : null} - - {result ? ( - - - setResult(null)}> - - Scan another - - - - ) : null} ); } @@ -151,7 +244,36 @@ const useStyles = createThemedStyles((colors) => ({ gap: 2, }, heading: { - marginBottom: spacing.lg, + marginBottom: spacing.xs, + }, + instruction: { + marginBottom: spacing.md, + }, + currentSignal: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginBottom: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + currentSignalIcon: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accentGlow, + }, + currentSignalCopy: { + flex: 1, + minWidth: 0, + gap: 1, }, center: { flex: 1, @@ -171,6 +293,18 @@ const useStyles = createThemedStyles((colors) => ({ paddingHorizontal: spacing.lg, alignItems: 'center', justifyContent: 'center', + flexDirection: 'row', + gap: spacing.sm, + }, + secondaryButton: { + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.bgSecondary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', }, linkButton: { alignItems: 'center', @@ -227,17 +361,6 @@ const useStyles = createThemedStyles((colors) => ({ borderBottomWidth: 3, borderBottomRightRadius: radius.sm, }, - busyOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - backgroundColor: 'rgba(0,0,0,0.35)', - }, controls: { position: 'absolute', left: spacing.lg, @@ -277,8 +400,13 @@ const useStyles = createThemedStyles((colors) => ({ errorCopy: { flex: 1, }, - resultWrap: { + resultState: { + flex: 1, + justifyContent: 'center', + gap: spacing.xl, + paddingBottom: spacing.xxl, + }, + resultActions: { gap: spacing.md, - marginBottom: spacing.lg, }, })); diff --git a/src/components/signal/SignalResultCard.tsx b/src/components/signal/SignalResultCard.tsx index 191bc28..96b5f37 100644 --- a/src/components/signal/SignalResultCard.tsx +++ b/src/components/signal/SignalResultCard.tsx @@ -11,24 +11,30 @@ import type { SignalPayload } from '@boof2015/astra-signal'; * playable track (local library match, then online lookup) is the next phase; * for now it confirms the round-trip — the make-or-break for the format. */ -export function SignalResultCard({ payload }: { payload: SignalPayload }) { +export function SignalResultCard({ + payload, + compact = false, +}: { + payload: SignalPayload; + compact?: boolean; +}) { const styles = useStyles(); const colors = useColors(); const title = payload.title.trim(); const artist = payload.artist.trim(); return ( - - - - - Signal found - + + + + + SIGNAL FOUND + {title || 'Unknown title'} - + {artist || 'Unknown artist'} {payload.durationSec > 0 ? ( @@ -45,26 +51,45 @@ export function SignalResultCard({ payload }: { payload: SignalPayload }) { const useStyles = createThemedStyles((colors) => ({ card: { - borderRadius: radius.md, + minHeight: 230, + borderRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.glassBg, - padding: spacing.lg, - gap: spacing.xs, - }, - badge: { - flexDirection: 'row', + padding: spacing.xl, + gap: spacing.sm, alignItems: 'center', - gap: spacing.xs, - alignSelf: 'flex-start', - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: radius.pill, + justifyContent: 'center', + }, + signalMark: { + width: 54, + height: 54, + borderRadius: 27, + alignItems: 'center', + justifyContent: 'center', backgroundColor: colors.accentGlow, marginBottom: spacing.sm, }, + compactCard: { + minHeight: 0, + padding: spacing.lg, + gap: spacing.xs, + }, + compactSignalMark: { + width: 42, + height: 42, + borderRadius: 21, + marginBottom: spacing.xs, + }, + foundLabel: { + letterSpacing: 0.8, + }, title: { marginTop: spacing.xs, + textAlign: 'center', + }, + artist: { + textAlign: 'center', }, metaRow: { flexDirection: 'row', diff --git a/src/components/signal/SignalScanTransition.tsx b/src/components/signal/SignalScanTransition.tsx new file mode 100644 index 0000000..8544659 --- /dev/null +++ b/src/components/signal/SignalScanTransition.tsx @@ -0,0 +1,482 @@ +import { useEffect, useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { + Easing, + cancelAnimation, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, + type SharedValue, +} from 'react-native-reanimated'; +import { encodeSignal, type SignalPayload } from '@boof2015/astra-signal'; +import { Text } from '@/components/Text'; +import { SignalCode } from '@/components/signal/SignalCode'; +import { SignalResultCard } from '@/components/signal/SignalResultCard'; +import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; + +export type SignalScanPhase = 'idle' | 'reading' | 'success' | 'failure'; + +interface SignalScanTransitionProps { + phase: SignalScanPhase; + width: number; + height: number; + payload: SignalPayload | null; + onScanAnother: () => void; + onDone: () => void; +} + +const CODE_FG = '#0b0b12'; +const CODE_BG = '#f4f4f6'; +const PLACEHOLDER_COLUMNS = 40; +const PLACEHOLDER_LEVELS = 4; +const PLACEHOLDER_CHUNKS = 10; +const PLACEHOLDER_STRUCTURES = 4; +const PLACEHOLDER_TRANSITION_START = 0.12; +const PLACEHOLDER_TRANSITION_END = 0.78; +const PLACEHOLDER_UPPER_STRUCTURES = [ + '32113131144213313314341341344212232232', + '12242324332343223433134344232332322424', + '34241341144211313421324134431321242123', + '24324314111412444413311432143222313213', +] as const; +const PLACEHOLDER_LOWER_STRUCTURES = [ + '44324232322142244243343132331243424114', + '42221243244112214131412221131124441323', + '43111413331343431142314112233243121433', + '34441133421331212424112143414432243134', +] as const; + +function placeholderChunkForIndex(index: number): number { + 'worklet'; + const innerIndex = index - 1; + if (innerIndex < 4) return 0; + if (innerIndex < 7) return 1; + if (innerIndex < 12) return 2; + if (innerIndex < 14) return 3; + if (innerIndex < 18) return 4; + if (innerIndex < 23) return 5; + if (innerIndex < 26) return 6; + if (innerIndex < 30) return 7; + if (innerIndex < 33) return 8; + return 9; +} + +function PlaceholderCell({ + index, + level, + side, + cycle, +}: { + index: number; + level: number; + side: 'upper' | 'lower'; + cycle: SharedValue; +}) { + const cellStyle = useAnimatedStyle(() => { + if (index === 0 || index === PLACEHOLDER_COLUMNS - 1) return { opacity: 1 }; + const structureProgress = cycle.value * PLACEHOLDER_STRUCTURES; + const currentStructure = Math.floor(structureProgress) % PLACEHOLDER_STRUCTURES; + const nextStructure = (currentStructure + 1) % PLACEHOLDER_STRUCTURES; + const stageProgress = structureProgress - Math.floor(structureProgress); + const transitionProgress = Math.max( + 0, + Math.min( + 1, + (stageProgress - PLACEHOLDER_TRANSITION_START) + / (PLACEHOLDER_TRANSITION_END - PLACEHOLDER_TRANSITION_START) + ) + ); + const chunk = placeholderChunkForIndex(index); + const orderMultiplier = currentStructure % 2 === 0 ? 7 : 3; + const orderOffset = (currentStructure * 3 + 1) % PLACEHOLDER_CHUNKS; + const updatePosition = (chunk * orderMultiplier + orderOffset) % PLACEHOLDER_CHUNKS; + const timingJitter = (((updatePosition * 7 + currentStructure * 5) % 5) - 2) * 0.018; + const switchPoint = Math.max( + 0.04, + Math.min(0.96, (updatePosition + 0.5) / PLACEHOLDER_CHUNKS + timingJitter) + ); + const structure = transitionProgress >= switchPoint ? nextStructure : currentStructure; + const structureLevels = side === 'upper' + ? PLACEHOLDER_UPPER_STRUCTURES[structure] + : PLACEHOLDER_LOWER_STRUCTURES[structure]; + const activeLevels = structureLevels.charCodeAt(index - 1) - 48; + return { opacity: level < activeLevels ? 1 : 0 }; + }, [index, level, side]); + + return ; +} + +function PlaceholderColumn({ index, cycle }: { index: number; cycle: SharedValue }) { + const levels = Array.from({ length: PLACEHOLDER_LEVELS }, (_, level) => level); + return ( + + + {levels.map((level) => ( + + ))} + + + {levels.map((level) => ( + + ))} + + + ); +} + +export function SignalScanTransition({ + phase, + width, + height, + payload, + onScanAnother, + onDone, +}: SignalScanTransitionProps) { + const themedStyles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const overlayOpacity = useSharedValue(0); + const lockProgress = useSharedValue(0); + const settleProgress = useSharedValue(0); + const resultProgress = useSharedValue(0); + const actualOpacity = useSharedValue(0); + const placeholderOpacity = useSharedValue(1); + const failurePulse = useSharedValue(0); + const cycle = useSharedValue(0); + + const layout = useMemo(() => (payload ? encodeSignal(payload) : null), [payload]); + const guideWidth = width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2); + const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio; + const guideLeft = width * SIGNAL_SCAN_GUIDE.horizontalInset; + const guideTop = height * SIGNAL_SCAN_GUIDE.top; + const targetAvailableWidth = Math.max(1, width - spacing.lg * 4); + const targetCodeWidth = layout + ? Math.min( + layout.tier === 'small' ? 280 : layout.tier === 'medium' ? 340 : targetAvailableWidth, + targetAvailableWidth + ) + : targetAvailableWidth; + const targetCardWidth = targetCodeWidth + spacing.lg * 2; + const targetCardHeight = layout + ? (layout.heightModules / layout.widthModules) * targetCodeWidth + spacing.lg * 2 + : guideHeight; + const targetLeft = (width - targetCardWidth) / 2; + const targetTop = spacing.lg; + const resultTop = targetTop + targetCardHeight + spacing.lg; + + useEffect(() => { + if (phase === 'idle') { + cancelAnimation(cycle); + overlayOpacity.value = 0; + lockProgress.value = 0; + settleProgress.value = 0; + resultProgress.value = 0; + actualOpacity.value = 0; + placeholderOpacity.value = 1; + failurePulse.value = 0; + cycle.value = 0; + return; + } + + if (phase === 'reading') { + overlayOpacity.value = withTiming(1, { duration: 150 }); + lockProgress.value = withTiming(1, { + duration: 240, + easing: Easing.out(Easing.cubic), + }); + settleProgress.value = 0; + resultProgress.value = 0; + actualOpacity.value = 0; + placeholderOpacity.value = 1; + failurePulse.value = 0; + cycle.value = 0; + cycle.value = withRepeat( + withTiming(1, { duration: 4800, easing: Easing.linear }), + -1, + false + ); + return; + } + + if (phase === 'success') { + cancelAnimation(cycle); + actualOpacity.value = withTiming(1, { duration: 240 }); + placeholderOpacity.value = withTiming(0, { duration: 220 }); + settleProgress.value = withDelay( + 100, + withTiming(1, { duration: 340, easing: Easing.out(Easing.cubic) }) + ); + resultProgress.value = withDelay( + 300, + withTiming(1, { duration: 280, easing: Easing.out(Easing.cubic) }) + ); + return; + } + + cancelAnimation(cycle); + failurePulse.value = withSequence( + withTiming(1, { duration: 120 }), + withTiming(0, { duration: 180 }) + ); + placeholderOpacity.value = withTiming(0, { duration: 180 }); + lockProgress.value = withDelay(80, withTiming(0, { duration: 240 })); + overlayOpacity.value = withDelay(220, withTiming(0, { duration: 220 })); + }, [ + actualOpacity, + cycle, + failurePulse, + lockProgress, + overlayOpacity, + phase, + placeholderOpacity, + resultProgress, + settleProgress, + ]); + + const overlayStyle = useAnimatedStyle(() => ({ opacity: overlayOpacity.value })); + const guideStyle = useAnimatedStyle(() => ({ + opacity: 1 - lockProgress.value, + transform: [{ scaleX: interpolate(lockProgress.value, [0, 1], [1, 0.84]) }], + })); + const cardStyle = useAnimatedStyle(() => ({ + left: interpolate(settleProgress.value, [0, 1], [guideLeft, targetLeft]), + top: interpolate(settleProgress.value, [0, 1], [guideTop, targetTop]), + width: interpolate(settleProgress.value, [0, 1], [guideWidth, targetCardWidth]), + height: interpolate(settleProgress.value, [0, 1], [guideHeight, targetCardHeight]), + opacity: lockProgress.value, + transform: [{ scale: interpolate(lockProgress.value, [0, 1], [0.94, 1]) }], + }), [guideHeight, guideLeft, guideTop, guideWidth, targetCardHeight, targetCardWidth, targetLeft, targetTop]); + const placeholderStyle = useAnimatedStyle(() => ({ opacity: placeholderOpacity.value })); + const actualStyle = useAnimatedStyle(() => ({ opacity: actualOpacity.value })); + const failureStyle = useAnimatedStyle(() => ({ opacity: failurePulse.value })); + const resultStyle = useAnimatedStyle(() => ({ + opacity: resultProgress.value, + transform: [{ translateY: interpolate(resultProgress.value, [0, 1], [18, 0]) }], + })); + + if (phase === 'idle' || width <= 0 || height <= 0) return null; + + return ( + + + + + + + + + + + {Array.from({ length: PLACEHOLDER_COLUMNS }, (_, index) => ( + + ))} + + + {layout ? ( + + + + ) : null} + + + + + {payload ? ( + + + + + + + Scan another + + + + + Done + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + placeholderField: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + marginHorizontal: spacing.lg, + marginVertical: spacing.md, + flexDirection: 'row', + }, + placeholderColumn: { + flex: 1, + height: '100%', + }, + placeholderStack: { + position: 'absolute', + left: 0, + right: 0, + height: '39%', + }, + placeholderUpperStack: { + bottom: '50%', + flexDirection: 'column-reverse', + }, + placeholderLowerStack: { + top: '50%', + }, + placeholderCell: { + flex: 1, + marginHorizontal: -StyleSheet.hairlineWidth, + marginVertical: -StyleSheet.hairlineWidth, + backgroundColor: CODE_FG, + }, + actualSignal: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +const useStyles = createThemedStyles((colors) => ({ + overlay: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 20, + overflow: 'hidden', + backgroundColor: colors.bgPrimary, + }, + guide: { + position: 'absolute', + }, + guideCorner: { + position: 'absolute', + width: 30, + height: 22, + borderColor: colors.accent, + }, + guideTopLeft: { + left: 0, + top: 0, + borderLeftWidth: 3, + borderTopWidth: 3, + borderTopLeftRadius: radius.sm, + }, + guideTopRight: { + right: 0, + top: 0, + borderRightWidth: 3, + borderTopWidth: 3, + borderTopRightRadius: radius.sm, + }, + guideBottomLeft: { + left: 0, + bottom: 0, + borderLeftWidth: 3, + borderBottomWidth: 3, + borderBottomLeftRadius: radius.sm, + }, + guideBottomRight: { + right: 0, + bottom: 0, + borderRightWidth: 3, + borderBottomWidth: 3, + borderBottomRightRadius: radius.sm, + }, + carrier: { + position: 'absolute', + overflow: 'hidden', + borderRadius: radius.lg, + backgroundColor: CODE_BG, + elevation: 8, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.25, + shadowRadius: 16, + }, + failureOutline: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + borderWidth: 3, + borderRadius: radius.lg, + borderColor: colors.warning, + }, + resultPanel: { + position: 'absolute', + left: spacing.lg, + right: spacing.lg, + gap: spacing.md, + }, + resultActions: { + flexDirection: 'row', + gap: spacing.md, + }, + primaryButton: { + minHeight: 48, + flex: 1.35, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + backgroundColor: colors.accent, + }, + secondaryButton: { + minHeight: 48, + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + }, +}));