astra signal UI/UX improvements

This commit is contained in:
Boof2015
2026-07-15 02:48:25 -04:00
parent ae3a49cf68
commit e6b2af0ae2
7 changed files with 822 additions and 128 deletions
+36 -3
View File
@@ -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
View File
@@ -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,
+216 -88
View File
@@ -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,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 (
<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>
{!permission ? (
<View style={styles.center} />
) : !permission.granted ? (
<View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan a Signal.</Text>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}>
Allow camera
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.linkButton} onPress={() => void pickImage()}>
<Text variant="body" color={colors.accent}>
Or pick a Signal image
</Text>
</Pressable>
{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>
) : (
<View style={styles.scannerFrame}>
<CameraView
ref={cameraRef}
style={styles.camera}
facing="back"
onLayout={(event) => setPreviewSize(event.nativeEvent.layout)}
/>
<View pointerEvents="none" style={styles.scanGuide}>
<View style={[styles.guideCorner, styles.guideTopLeft]} />
<View style={[styles.guideCorner, styles.guideTopRight]} />
<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 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} />
) : !permission.granted ? (
<View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan a Signal.</Text>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}>
Allow camera
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.linkButton} onPress={() => void pickImage()}>
<Text variant="body" color={colors.accent}>
Or pick a Signal image
</Text>
</Pressable>
</View>
) : (
<View style={styles.scannerFrame}>
<CameraView
ref={cameraRef}
style={styles.camera}
facing="back"
onLayout={(event) => setPreviewSize(event.nativeEvent.layout)}
/>
<View pointerEvents="none" style={styles.scanGuide}>
<View style={[styles.guideCorner, styles.guideTopLeft]} />
<View style={[styles.guideCorner, styles.guideTopRight]} />
<View style={[styles.guideCorner, styles.guideBottomLeft]} />
<View style={[styles.guideCorner, styles.guideBottomRight]} />
</View>
<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} />
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.shutter} onPress={() => void capture()} hitSlop={8}>
<Ionicons name="pulse" size={26} color={colors.accentTextStrong} />
</Pressable>
<View style={styles.iconButton} />
</View>
<SignalScanTransition
phase={phase}
width={previewSize.width}
height={previewSize.height}
payload={result}
onScanAnother={scanAnother}
onDone={() => router.back()}
/>
</View>
)}
{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}>
{error}
</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} />
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.shutter} onPress={() => void capture()} hitSlop={8}>
<Ionicons name="pulse" size={26} color={colors.accentTextStrong} />
</Pressable>
<View style={styles.iconButton} />
</View>
</View>
</>
)}
{error ? (
<View style={styles.errorPanel}>
<Ionicons name="alert-circle-outline" size={20} color={colors.warning} />
<Text variant="body" color={colors.textPrimary} style={styles.errorCopy}>
{error}
</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,
},
}));