diff --git a/package.json b/package.json index 8973fc7..2c39b41 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts", "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts", "test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", - "test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts", + "test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts", diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx index d151cbd..3877779 100644 --- a/src/app/signal/scan.tsx +++ b/src/app/signal/scan.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as DocumentPicker from 'expo-document-picker'; @@ -6,14 +6,21 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; import { Text } from '@/components/Text'; -import { SignalResultCard } from '@/components/signal/SignalResultCard'; +import { + SignalResolutionPanel, + type SignalResultActionState, +} from '@/components/signal/SignalResolutionPanel'; import { SignalScanTransition, type SignalScanPhase, } from '@/components/signal/SignalScanTransition'; import { decodeSignalFromUri } from '@/audio/signalDecodeImage'; +import { matchSignalToLibrary } from '@/audio/signalLocalMatch'; import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry'; -import { usePlayerStore } from '@/stores/playerStore'; +import { enqueueEnd, playTracks } from '@/audio/playbackController'; +import { dbTrackToTrack } from '@/library/trackAdapter'; +import { playHaptic } from '@/lib/haptics'; +import { useLibraryStore } from '@/stores/libraryStore'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; @@ -27,15 +34,22 @@ export default function SignalScanScreen() { const ripple = useRipple(); const colors = useColors(); const router = useRouter(); - const currentTrack = usePlayerStore((state) => state.currentTrack); + const libraryInitialized = useLibraryStore((state) => state.initialized); + const libraryTracks = useLibraryStore((state) => state.tracks); 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 [actionState, setActionState] = useState('idle'); + const [actionError, setActionError] = useState(null); const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 }); const readingStartedAt = useRef(0); + const resolution = useMemo( + () => result && libraryInitialized ? matchSignalToLibrary(result, libraryTracks) : null, + [libraryInitialized, libraryTracks, result] + ); useEffect(() => { if (phase !== 'failure') return; @@ -104,9 +118,52 @@ export default function SignalScanScreen() { const scanAnother = () => { setResult(null); setError(null); + setActionState('idle'); + setActionError(null); setPhase('idle'); }; + + const playMatchedTrack = async (track: (typeof libraryTracks)[number]) => { + if (actionState === 'playing' || actionState === 'queueing') return; + playHaptic('confirm'); + setActionState('playing'); + setActionError(null); + try { + await playTracks([dbTrackToTrack(track)]); + router.back(); + } catch { + setActionState('idle'); + setActionError("Couldn't start this track. Try playing it from your library."); + } + }; + + const queueMatchedTrack = async (track: (typeof libraryTracks)[number]) => { + if (actionState !== 'idle') return; + playHaptic('confirm'); + setActionState('queueing'); + setActionError(null); + try { + await enqueueEnd(dbTrackToTrack(track)); + setActionState('queued'); + } catch { + setActionState('idle'); + setActionError("Couldn't add this track to the queue."); + } + }; + const standaloneResult = result !== null && !permission?.granted; + const resultContent = result ? ( + void playMatchedTrack(track)} + onQueue={(track) => void queueMatchedTrack(track)} + onScanAnother={scanAnother} + onDone={() => router.back()} + /> + ) : null; return ( @@ -124,20 +181,7 @@ export default function SignalScanScreen() { {standaloneResult ? ( - - - - - - Scan another - - - router.back()}> - - Done - - - + {resultContent} ) : ( <> @@ -145,29 +189,6 @@ export default function SignalScanScreen() { 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 ? ( @@ -213,8 +234,7 @@ export default function SignalScanScreen() { width={previewSize.width} height={previewSize.height} payload={result} - onScanAnother={scanAnother} - onDone={() => router.back()} + resultContent={resultContent} /> )} @@ -249,32 +269,6 @@ const useStyles = createThemedStyles((colors) => ({ 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, }, @@ -296,16 +290,6 @@ const useStyles = createThemedStyles((colors) => ({ 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', paddingVertical: spacing.sm, @@ -402,11 +386,7 @@ const useStyles = createThemedStyles((colors) => ({ }, resultState: { flex: 1, - justifyContent: 'center', - gap: spacing.xl, - paddingBottom: spacing.xxl, - }, - resultActions: { - gap: spacing.md, + paddingTop: spacing.lg, + paddingBottom: spacing.lg, }, })); diff --git a/src/audio/signalLocalMatch.test.mts b/src/audio/signalLocalMatch.test.mts new file mode 100644 index 0000000..a8f2188 --- /dev/null +++ b/src/audio/signalLocalMatch.test.mts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { matchSignalToLibrary, type SignalMatchableTrack } from './signalLocalMatch.ts'; + +interface TestTrack extends SignalMatchableTrack { + album: string; +} + +function track(overrides: Partial = {}): TestTrack { + return { + path: '/music/replay.flac', + title: 'Replay', + artist: 'ナナツカゼ', + album: 'Signal Tests', + duration: 213.6, + ...overrides, + }; +} + +test('matches Unicode and punctuation without losing identity', () => { + const candidate = track({ artist: 'N!GHT', title: '#iwannadance', duration: 222.3 }); + const result = matchSignalToLibrary( + { artist: 'N!GHT', title: '#iwannadance', durationSec: 222 }, + [candidate] + ); + assert.equal(result.kind, 'match'); + if (result.kind !== 'match') return; + assert.equal(result.candidate.track, candidate); + assert.equal(result.candidate.match, 'exact'); +}); + +test('uses duration to reject a different version of an exact metadata match', () => { + const result = matchSignalToLibrary( + { artist: 'ナナツカゼ', title: 'Replay', durationSec: 214 }, + [track(), track({ path: '/music/replay-live.flac', duration: 278 })] + ); + assert.equal(result.kind, 'match'); + if (result.kind !== 'match') return; + assert.equal(result.candidate.track.path, '/music/replay.flac'); +}); + +test('returns ambiguity instead of guessing between equivalent local versions', () => { + const result = matchSignalToLibrary( + { artist: 'ナナツカゼ', title: 'Replay', durationSec: 214 }, + [ + track({ path: '/music/replay-flac.flac', album: 'Replay', duration: 213.6 }), + track({ path: '/music/replay-mp3.mp3', album: 'Singles', duration: 214.1 }), + ] + ); + assert.equal(result.kind, 'ambiguous'); + if (result.kind !== 'ambiguous') return; + assert.deepEqual(result.candidates.map((entry) => entry.track.path), [ + '/music/replay-mp3.mp3', + '/music/replay-flac.flac', + ]); +}); + +test('allows a duration-gated punctuation and diacritic fallback', () => { + const candidate = track({ artist: 'Beyoncé', title: 'Déjà Vu', duration: 223.4 }); + const result = matchSignalToLibrary( + { artist: 'Beyonce', title: 'Deja Vu', durationSec: 223 }, + [candidate] + ); + assert.equal(result.kind, 'match'); + if (result.kind !== 'match') return; + assert.equal(result.candidate.match, 'normalized'); +}); + +test('prefers punctuation-preserving candidates over a closer relaxed candidate', () => { + const exact = track({ path: '/music/night.flac', artist: 'N!GHT', title: 'Replay', duration: 221.8 }); + const relaxed = track({ path: '/music/n-ght.flac', artist: 'N GHT', title: 'Replay', duration: 220 }); + const result = matchSignalToLibrary( + { artist: 'N!GHT', title: 'Replay', durationSec: 220 }, + [relaxed, exact] + ); + assert.equal(result.kind, 'match'); + if (result.kind !== 'match') return; + assert.equal(result.candidate.track, exact); +}); + +test('does not relax empty or duration-free metadata into a plausible match', () => { + assert.deepEqual( + matchSignalToLibrary({ artist: '', title: 'Replay', durationSec: 214 }, [track()]), + { kind: 'none' } + ); + assert.deepEqual( + matchSignalToLibrary( + { artist: 'N GHT', title: 'Replay', durationSec: 0 }, + [track({ artist: 'N!GHT' })] + ), + { kind: 'none' } + ); +}); + +test('does not ignore a Signal duration when the library duration is unavailable', () => { + assert.deepEqual( + matchSignalToLibrary( + { artist: 'ナナツカゼ', title: 'Replay', durationSec: 214 }, + [track({ duration: 0 })] + ), + { kind: 'none' } + ); +}); diff --git a/src/audio/signalLocalMatch.ts b/src/audio/signalLocalMatch.ts new file mode 100644 index 0000000..946fd02 --- /dev/null +++ b/src/audio/signalLocalMatch.ts @@ -0,0 +1,111 @@ +import type { SignalPayload } from '@boof2015/astra-signal'; + +export interface SignalMatchableTrack { + path: string; + title: string; + artist: string; + duration: number; +} + +export interface SignalLocalCandidate { + track: T; + match: 'exact' | 'normalized'; + durationDeltaSec: number | null; +} + +export type SignalLocalMatchResult = + | { kind: 'match'; candidate: SignalLocalCandidate } + | { kind: 'ambiguous'; candidates: SignalLocalCandidate[] } + | { kind: 'none' }; + +type SignalIdentity = Pick; + +const EXACT_DURATION_TOLERANCE_SEC = 3; +const NORMALIZED_DURATION_TOLERANCE_SEC = 2; + +function exactTextForm(value: string): string { + return value.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +function relaxedTextForm(value: string): string { + return value + .normalize('NFKD') + .replace(/\p{M}+/gu, '') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function usableDuration(value: number): number | null { + return Number.isFinite(value) && value > 0 ? value : null; +} + +function byDurationThenPath( + left: SignalLocalCandidate, + right: SignalLocalCandidate +): number { + const leftDelta = left.durationDeltaSec ?? Number.POSITIVE_INFINITY; + const rightDelta = right.durationDeltaSec ?? Number.POSITIVE_INFINITY; + return leftDelta - rightDelta || left.track.path.localeCompare(right.track.path); +} + +function resultFromCandidates( + candidates: SignalLocalCandidate[] +): SignalLocalMatchResult { + candidates.sort(byDurationThenPath); + if (candidates.length === 0) return { kind: 'none' }; + if (candidates.length === 1) return { kind: 'match', candidate: candidates[0] }; + return { kind: 'ambiguous', candidates }; +} + +/** + * Resolve a decoded Signal against the already-loaded on-device library. + * + * Punctuation-preserving comparisons always win. A punctuation/diacritic- + * insensitive fallback is only accepted when both sides have usable, closely + * matching durations, so names such as N!GHT retain their stronger identity. + */ +export function matchSignalToLibrary( + signal: SignalIdentity, + tracks: readonly T[] +): SignalLocalMatchResult { + const signalTitle = exactTextForm(signal.title); + const signalArtist = exactTextForm(signal.artist); + if (!signalTitle || !signalArtist) return { kind: 'none' }; + + const relaxedSignalTitle = relaxedTextForm(signal.title); + const relaxedSignalArtist = relaxedTextForm(signal.artist); + const signalDuration = usableDuration(signal.durationSec); + const exact: SignalLocalCandidate[] = []; + const normalized: SignalLocalCandidate[] = []; + + for (const track of tracks) { + const title = exactTextForm(track.title); + const artist = exactTextForm(track.artist); + const trackDuration = usableDuration(track.duration); + const durationDeltaSec = signalDuration !== null && trackDuration !== null + ? Math.abs(signalDuration - trackDuration) + : null; + + if (title === signalTitle && artist === signalArtist) { + if (signalDuration !== null && trackDuration === null) continue; + if (durationDeltaSec !== null && durationDeltaSec > EXACT_DURATION_TOLERANCE_SEC) continue; + exact.push({ track, match: 'exact', durationDeltaSec }); + continue; + } + + // Relaxed matching without duration would be too eager: punctuation can be + // meaningful artist/title data, and a false local match is worse than none. + if (signalDuration === null || trackDuration === null) continue; + if (durationDeltaSec === null || durationDeltaSec > NORMALIZED_DURATION_TOLERANCE_SEC) continue; + if ( + relaxedTextForm(track.title) === relaxedSignalTitle + && relaxedTextForm(track.artist) === relaxedSignalArtist + ) { + normalized.push({ track, match: 'normalized', durationDeltaSec }); + } + } + + return resultFromCandidates(exact.length > 0 ? exact : normalized); +} diff --git a/src/components/signal/SignalResolutionPanel.tsx b/src/components/signal/SignalResolutionPanel.tsx new file mode 100644 index 0000000..abf8a6c --- /dev/null +++ b/src/components/signal/SignalResolutionPanel.tsx @@ -0,0 +1,307 @@ +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { TrackRow } from '@/components/library/TrackRow'; +import { SignalResultCard } from '@/components/signal/SignalResultCard'; +import type { SignalLocalMatchResult } from '@/audio/signalLocalMatch'; +import type { DbTrack } from '@/types/library'; +import type { SignalPayload } from '@boof2015/astra-signal'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; + +export type SignalResultActionState = 'idle' | 'playing' | 'queueing' | 'queued'; + +interface SignalResolutionPanelProps { + payload: SignalPayload; + resolution: SignalLocalMatchResult | null; + actionState: SignalResultActionState; + actionError: string | null; + onPlay: (track: DbTrack) => void; + onQueue: (track: DbTrack) => void; + onScanAnother: () => void; + onDone: () => void; +} + +export function SignalResolutionPanel({ + payload, + resolution, + actionState, + actionError, + onPlay, + onQueue, + onScanAnother, + onDone, +}: SignalResolutionPanelProps) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const actionBusy = actionState === 'playing' || actionState === 'queueing'; + + const footer = ( + + + + Scan another + + + Done + + + ); + + if (!resolution) { + return ( + + + + + + Checking your library… + + + {footer} + + ); + } + + if (resolution.kind === 'none') { + return ( + + + + + + Not in your library + + The Signal decoded correctly, but no local track matched. + + + + {footer} + + ); + } + + if (resolution.kind === 'ambiguous') { + return ( + + + + + + + + FOUND IN YOUR LIBRARY + + Choose a version + + + 3} + > + {resolution.candidates.map(({ track }) => ( + onPlay(track)} + /> + ))} + + + Tap a version to play it. + + {actionError ? ( + + {actionError} + + ) : null} + {footer} + + ); + } + + const track = resolution.candidate.track; + return ( + + + + + + + + IN YOUR LIBRARY + + Ready to play + + + + onPlay(track)} + /> + + + onPlay(track)} + disabled={actionBusy} + accessibilityRole="button" + > + + + {actionState === 'playing' ? 'Starting…' : 'Play now'} + + + onQueue(track)} + disabled={actionBusy || actionState === 'queued'} + accessibilityRole="button" + > + + + {actionState === 'queueing' + ? 'Adding…' + : actionState === 'queued' + ? 'Added' + : 'Add to queue'} + + + + + {actionError ? ( + + {actionError} + + ) : null} + {footer} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + root: { + flexShrink: 1, + gap: spacing.md, + }, + sectionHeading: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + matchIcon: { + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accentGlow, + }, + headingCopy: { + flex: 1, + minWidth: 0, + gap: 2, + }, + eyebrow: { + letterSpacing: 0.7, + }, + statusLine: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + }, + messageCard: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + padding: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + }, + messageCopy: { + flex: 1, + minWidth: 0, + gap: spacing.xs, + }, + candidateList: { + flexShrink: 1, + maxHeight: 226, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.glassBorder, + }, + centerCopy: { + textAlign: 'center', + }, + playActions: { + flexDirection: 'row', + gap: spacing.sm, + }, + primaryButton: { + minHeight: 48, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + backgroundColor: colors.accent, + }, + secondaryButton: { + minHeight: 48, + flex: 1.15, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgSecondary, + }, + disabledButton: { + opacity: 0.6, + }, + footerActions: { + flexDirection: 'row', + gap: spacing.sm, + }, + footerButton: { + minHeight: 40, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: radius.sm, + }, +})); diff --git a/src/components/signal/SignalResultCard.tsx b/src/components/signal/SignalResultCard.tsx index 96b5f37..9e873bb 100644 --- a/src/components/signal/SignalResultCard.tsx +++ b/src/components/signal/SignalResultCard.tsx @@ -6,11 +6,7 @@ import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import type { SignalPayload } from '@boof2015/astra-signal'; -/** - * Shows the song a scanned/imported Signal decoded to. Resolving this to a - * 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. - */ +/** Shows the database-free metadata recovered from a scanned/imported Signal. */ export function SignalResultCard({ payload, compact = false, diff --git a/src/components/signal/SignalScanTransition.tsx b/src/components/signal/SignalScanTransition.tsx index 8544659..a302c8f 100644 --- a/src/components/signal/SignalScanTransition.tsx +++ b/src/components/signal/SignalScanTransition.tsx @@ -1,6 +1,5 @@ -import { useEffect, useMemo } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; +import { useEffect, useMemo, type ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; import Animated, { Easing, cancelAnimation, @@ -14,13 +13,10 @@ import Animated, { 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'; +import { createThemedStyles } from '@/theme/themed'; export type SignalScanPhase = 'idle' | 'reading' | 'success' | 'failure'; @@ -29,8 +25,7 @@ interface SignalScanTransitionProps { width: number; height: number; payload: SignalPayload | null; - onScanAnother: () => void; - onDone: () => void; + resultContent: ReactNode; } const CODE_FG = '#0b0b12'; @@ -137,12 +132,9 @@ export function SignalScanTransition({ width, height, payload, - onScanAnother, - onDone, + resultContent, }: SignalScanTransitionProps) { const themedStyles = useStyles(); - const colors = useColors(); - const ripple = useRipple(); const overlayOpacity = useSharedValue(0); const lockProgress = useSharedValue(0); const settleProgress = useSharedValue(0); @@ -171,6 +163,7 @@ export function SignalScanTransition({ const targetLeft = (width - targetCardWidth) / 2; const targetTop = spacing.lg; const resultTop = targetTop + targetCardHeight + spacing.lg; + const resultMaxHeight = Math.max(1, height - resultTop - spacing.lg); useEffect(() => { if (phase === 'idle') { @@ -304,29 +297,14 @@ export function SignalScanTransition({ {payload ? ( - - - - - - - Scan another - - - - - Done - - - + + {resultContent} ) : null} @@ -451,32 +429,5 @@ const useStyles = createThemedStyles((colors) => ({ 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, }, }));