mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
astra signal UI/UX improvements
This commit is contained in:
Generated
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const scanButton = (
|
||||
<Pressable
|
||||
style={styles.mastheadSearch}
|
||||
android_ripple={ripple.icon(22)}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
onPress={onScan}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scan an Astra Signal"
|
||||
hitSlop={4}
|
||||
>
|
||||
<Ionicons name="scan-outline" size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const utilityButtons = (
|
||||
<View style={styles.mastheadActions}>
|
||||
{scanButton}
|
||||
{searchButton}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (mode === 'off') {
|
||||
return <View style={styles.mastheadUtility}>{searchButton}</View>;
|
||||
return <View style={styles.mastheadUtility}>{utilityButtons}</View>;
|
||||
}
|
||||
|
||||
const primary = mode === 'clock' ? formatHomeClockTime(clockNow) : greeting.primary;
|
||||
@@ -208,7 +231,7 @@ function HomeMasthead({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{searchButton}
|
||||
{utilityButtons}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -600,6 +623,7 @@ export default function HomeScreen() {
|
||||
};
|
||||
|
||||
const openSearch = () => openQuickSearch();
|
||||
const openSignalScanner = () => router.push('/signal/scan' as never);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
@@ -611,7 +635,11 @@ export default function HomeScreen() {
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
>
|
||||
<HomeMasthead mode={homeGreetingTextMode} onSearch={openSearch} />
|
||||
<HomeMasthead
|
||||
mode={homeGreetingTextMode}
|
||||
onSearch={openSearch}
|
||||
onScan={openSignalScanner}
|
||||
/>
|
||||
|
||||
<ScanProgress />
|
||||
|
||||
@@ -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,
|
||||
|
||||
+42
-16
@@ -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<SignalCodeHandle>(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 (
|
||||
<Screen>
|
||||
<NowPlayingWash
|
||||
artworkUri={artworkWash}
|
||||
offset={{ top: 0, left: -spacing.lg, right: -spacing.lg }}
|
||||
/>
|
||||
<View style={styles.header}>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||
@@ -59,9 +66,6 @@ export default function SignalScreen() {
|
||||
Back
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable android_ripple={ripple.icon(22)} style={styles.scanBtn} onPress={() => router.navigate('/signal/scan' as never)} hitSlop={8}>
|
||||
<Ionicons name="scan-outline" size={22} color={colors.accent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text variant="title" style={styles.heading}>
|
||||
@@ -77,6 +81,18 @@ export default function SignalScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.body}>
|
||||
<View style={styles.trackMeta}>
|
||||
<Text variant="label" color={colors.accent}>
|
||||
NOW PLAYING
|
||||
</Text>
|
||||
<Text variant="heading" style={styles.trackTitle} numberOfLines={2}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||
{track.artist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.codeCard}>
|
||||
<SignalCode
|
||||
ref={codeRef}
|
||||
@@ -88,18 +104,16 @@ export default function SignalScreen() {
|
||||
exportBackground={CODE_BG}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="heading" style={styles.trackTitle} numberOfLines={1}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
|
||||
{track.artist}
|
||||
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.hint}>
|
||||
Keep the full white card visible when another phone scans it.
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void shareImage()}>
|
||||
<Ionicons name="share-outline" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Share Signal
|
||||
Share image
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={() => 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,
|
||||
|
||||
+170
-42
@@ -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<CameraView>(null);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<SignalPayload | null>(null);
|
||||
const [phase, setPhase] = useState<SignalScanPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(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<void>((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,31 +72,101 @@ export default function SignalScanScreen() {
|
||||
|
||||
const capture = async () => {
|
||||
if (busy) return;
|
||||
beginReading();
|
||||
try {
|
||||
const photo = await cameraRef.current?.takePictureAsync({ quality: 1 });
|
||||
if (photo?.uri) await runDecode(photo.uri, true);
|
||||
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 (
|
||||
<Screen>
|
||||
<View style={styles.header}>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Signal
|
||||
Back
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Scan the Signal
|
||||
Scan a Signal
|
||||
</Text>
|
||||
{standaloneResult ? (
|
||||
<View style={styles.resultState}>
|
||||
<SignalResultCard payload={result} />
|
||||
<View style={styles.resultActions}>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={scanAnother}>
|
||||
<Ionicons name="scan-outline" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Scan another
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={() => router.back()}>
|
||||
<Text variant="body" color={colors.textPrimary}>
|
||||
Done
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.instruction}>
|
||||
Keep the full white Signal card visible and hold steady.
|
||||
</Text>
|
||||
|
||||
{currentTrack ? (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.currentSignal}
|
||||
onPress={() => router.push('/signal' as never)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`View the Signal for ${currentTrack.title} by ${currentTrack.artist}`}
|
||||
>
|
||||
<View style={styles.currentSignalIcon}>
|
||||
<Ionicons name="pulse" size={18} color={colors.accent} />
|
||||
</View>
|
||||
<View style={styles.currentSignalCopy}>
|
||||
<Text variant="label" color={colors.textTertiary}>
|
||||
NOW PLAYING
|
||||
</Text>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{currentTrack.title} · {currentTrack.artist}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={19} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{!permission ? (
|
||||
<View style={styles.center} />
|
||||
@@ -97,14 +199,6 @@ export default function SignalScanScreen() {
|
||||
<View style={[styles.guideCorner, styles.guideBottomLeft]} />
|
||||
<View style={[styles.guideCorner, styles.guideBottomRight]} />
|
||||
</View>
|
||||
{busy ? (
|
||||
<View style={styles.busyOverlay}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
<Text variant="label" color={colors.textPrimary}>
|
||||
Reading signal…
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.controls}>
|
||||
<Pressable android_ripple={ripple.icon(28)} style={styles.iconButton} onPress={() => void pickImage()} hitSlop={8}>
|
||||
<Ionicons name="image-outline" size={24} color={colors.textPrimary} />
|
||||
@@ -114,10 +208,18 @@ export default function SignalScanScreen() {
|
||||
</Pressable>
|
||||
<View style={styles.iconButton} />
|
||||
</View>
|
||||
<SignalScanTransition
|
||||
phase={phase}
|
||||
width={previewSize.width}
|
||||
height={previewSize.height}
|
||||
payload={result}
|
||||
onScanAnother={scanAnother}
|
||||
onDone={() => router.back()}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
{error && phase === 'idle' ? (
|
||||
<View style={styles.errorPanel}>
|
||||
<Ionicons name="alert-circle-outline" size={20} color={colors.warning} />
|
||||
<Text variant="body" color={colors.textPrimary} style={styles.errorCopy}>
|
||||
@@ -125,17 +227,8 @@ export default function SignalScanScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<View style={styles.resultWrap}>
|
||||
<SignalResultCard payload={result} />
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => setResult(null)}>
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Scan another
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.badge}>
|
||||
<Ionicons name="pulse" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Signal found
|
||||
</Text>
|
||||
<View style={[styles.card, compact && styles.compactCard]}>
|
||||
<View style={[styles.signalMark, compact && styles.compactSignalMark]}>
|
||||
<Ionicons name="pulse" size={25} color={colors.accent} />
|
||||
</View>
|
||||
<Text variant="label" color={colors.accent} style={styles.foundLabel}>
|
||||
SIGNAL FOUND
|
||||
</Text>
|
||||
<Text variant="title" style={styles.title}>
|
||||
{title || 'Unknown title'}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.artist}>
|
||||
{artist || 'Unknown artist'}
|
||||
</Text>
|
||||
{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',
|
||||
|
||||
@@ -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<number>;
|
||||
}) {
|
||||
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 <Animated.View style={[styles.placeholderCell, cellStyle]} />;
|
||||
}
|
||||
|
||||
function PlaceholderColumn({ index, cycle }: { index: number; cycle: SharedValue<number> }) {
|
||||
const levels = Array.from({ length: PLACEHOLDER_LEVELS }, (_, level) => level);
|
||||
return (
|
||||
<View style={styles.placeholderColumn}>
|
||||
<View style={[styles.placeholderStack, styles.placeholderUpperStack]}>
|
||||
{levels.map((level) => (
|
||||
<PlaceholderCell key={level} index={index} level={level} side="upper" cycle={cycle} />
|
||||
))}
|
||||
</View>
|
||||
<View style={[styles.placeholderStack, styles.placeholderLowerStack]}>
|
||||
{levels.map((level) => (
|
||||
<PlaceholderCell key={level} index={index} level={level} side="lower" cycle={cycle} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Animated.View
|
||||
pointerEvents={phase === 'success' ? 'auto' : 'none'}
|
||||
style={[themedStyles.overlay, overlayStyle]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
themedStyles.guide,
|
||||
{ left: guideLeft, top: guideTop, width: guideWidth, height: guideHeight },
|
||||
guideStyle,
|
||||
]}
|
||||
>
|
||||
<View style={[themedStyles.guideCorner, themedStyles.guideTopLeft]} />
|
||||
<View style={[themedStyles.guideCorner, themedStyles.guideTopRight]} />
|
||||
<View style={[themedStyles.guideCorner, themedStyles.guideBottomLeft]} />
|
||||
<View style={[themedStyles.guideCorner, themedStyles.guideBottomRight]} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[themedStyles.carrier, cardStyle]}>
|
||||
<Animated.View style={[styles.placeholderField, placeholderStyle]}>
|
||||
{Array.from({ length: PLACEHOLDER_COLUMNS }, (_, index) => (
|
||||
<PlaceholderColumn key={index} index={index} cycle={cycle} />
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{layout ? (
|
||||
<Animated.View style={[styles.actualSignal, actualStyle]}>
|
||||
<SignalCode
|
||||
layout={layout}
|
||||
width={targetCodeWidth}
|
||||
foreground={CODE_FG}
|
||||
background={CODE_BG}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
<Animated.View pointerEvents="none" style={[themedStyles.failureOutline, failureStyle]} />
|
||||
</Animated.View>
|
||||
|
||||
{payload ? (
|
||||
<Animated.View style={[themedStyles.resultPanel, { top: resultTop }, resultStyle]}>
|
||||
<SignalResultCard payload={payload} compact />
|
||||
<View style={themedStyles.resultActions}>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={themedStyles.primaryButton}
|
||||
onPress={onScanAnother}
|
||||
>
|
||||
<Ionicons name="scan-outline" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Scan another
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={themedStyles.secondaryButton}
|
||||
onPress={onDone}
|
||||
>
|
||||
<Text variant="body" color={colors.textPrimary}>
|
||||
Done
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user