Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions apps/mobile/src/app/(tabs)/garage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,43 @@ import { useGarageStore } from '@/store/garageStore';
import type { CarRecord } from '@/store/persistence/carRepository';
import { usePortalStore } from '@/store/portalStore';
import { useSettingsStore } from '@/store/settingsStore';
import { catalogIdForUid, useIdentityStore } from '@/store/identityStore';
import { speedUnitLabel } from '@/speed/format';
import { colors, fontSize, fontWeight, radius, spacing } from '@/theme/tokens';
import { colors, elevation, fontSize, fontWeight, radius, spacing } from '@/theme/tokens';
import { carLabel, formatLastSeen, formatLap, formatMph } from '@/garage/format';
import { CarPhoto } from '@/catalog/CarPhoto';
import { useCarIdentity } from '@/catalog/useCarIdentity';

export default function GarageScreen() {
const insets = useSafeAreaInsets();
const cars = useGarageStore((s) => s.cars);
const onPortalUid = usePortalStore((s) => s.car?.uid ?? null);

// Count identified cars off a single identity snapshot (uid → casting → catalog).
const links = useIdentityStore((s) => s.links);
const identifications = useIdentityStore((s) => s.identifications);
const identifiedCount = cars.reduce(
(n, c) => (catalogIdForUid({ links, identifications }, c.uid) ? n + 1 : n),
0,
);
const onPortalHere = onPortalUid != null && cars.some((c) => c.uid === onPortalUid);

const summary =
cars.length === 0
? 'Your collection lives here'
: [identifiedCount > 0 ? `${identifiedCount} identified` : null, onPortalHere ? '1 on portal' : null]
.filter(Boolean)
.join(' · ') || 'Tap a car to identify it';

return (
<View style={[styles.screen, { paddingTop: insets.top + spacing(2) }]}>
<View style={styles.header}>
<Text style={styles.title}>Garage</Text>
<View style={styles.headerText}>
<Text style={styles.title}>Garage</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{summary}
</Text>
</View>
<Text style={styles.count}>{cars.length}</Text>
</View>

Expand All @@ -49,18 +73,21 @@ function CarRow({ car, onPortal }: { car: CarRecord; onPortal: boolean }) {
const speedUnit = useSettingsStore((s) => s.speedUnit);
const speedCalibration = useSettingsStore((s) => s.speedCalibration);
const display = { unit: speedUnit, calibration: speedCalibration };
const identity = useCarIdentity(car.uid);
const title = identity?.name ?? carLabel(car);
return (
<Link href={{ pathname: '/garage/[uid]', params: { uid: car.uid } }} asChild>
<Pressable style={({ pressed }) => [styles.row, onPortal && styles.rowOnPortal, pressed && styles.pressed]}>
<CarPhoto uri={identity?.image} size={56} rounded={radius.md} ring={!!identity} />
<View style={styles.rowMain}>
<View style={styles.rowTitleLine}>
<Text style={styles.carName} numberOfLines={1}>
{carLabel(car)}
{title}
</Text>
{onPortal && <Text style={styles.onPortal}>● on portal</Text>}
</View>
<Text style={styles.carMeta} numberOfLines={1}>
{car.serial ? `#${car.serial}` : car.uid}
{identity?.series ?? (car.serial ? `#${car.serial}` : car.uid)}
{' · '}
{formatLastSeen(car.lastSeen)}
</Text>
Expand Down Expand Up @@ -99,7 +126,9 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing(5),
paddingBottom: spacing(3),
},
title: { color: colors.textPrimary, fontSize: fontSize.xl, fontWeight: fontWeight.heavy, flex: 1 },
headerText: { flex: 1, gap: 2 },
title: { color: colors.textPrimary, fontSize: fontSize.xl, fontWeight: fontWeight.heavy },
subtitle: { color: colors.textSecondary, fontSize: fontSize.sm },
count: {
color: colors.textSecondary,
fontSize: fontSize.md,
Expand All @@ -125,8 +154,9 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderRadius: radius.md,
padding: spacing(4),
...elevation.card,
},
rowOnPortal: { borderColor: colors.accent },
rowOnPortal: { borderColor: colors.accent, backgroundColor: colors.surfaceRaised, ...elevation.accentGlow },
rowMain: { flex: 1, gap: 4 },
rowTitleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing(2) },
carName: { color: colors.textPrimary, fontSize: fontSize.md, fontWeight: fontWeight.bold, flexShrink: 1 },
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { BlePhase } from '@/ble/types';
import { usePortalStore } from '@/store/portalStore';
import { useSettingsStore } from '@/store/settingsStore';
import { formatBestSpeed, speedUnitLabel, type SpeedDisplay } from '@/speed/format';
import { colors, fontSize, fontWeight, radius, spacing, speedGauge } from '@/theme/tokens';
import { colors, elevation, fontSize, fontWeight, radius, spacing, speedGauge } from '@/theme/tokens';

/** How long the needle holds a pass before easing back toward zero. */
const NEEDLE_HOLD_MS = 1300;
Expand Down Expand Up @@ -329,6 +329,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing(3),
alignItems: 'center',
gap: 2,
...elevation.card,
},
statLabel: {
color: colors.textMuted,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function RootLayout() {
{/* Secondary screens push over the tabs — reached from the More tab
or from a tab's detail links. */}
<Stack.Screen name="garage/[uid]" />
<Stack.Screen name="identify" options={{ presentation: 'modal' }} />
<Stack.Screen name="history/[id]" />
<Stack.Screen name="achievements" />
<Stack.Screen name="live" />
Expand Down
99 changes: 93 additions & 6 deletions apps/mobile/src/app/garage/[uid].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { usePortalStore } from '@/store/portalStore';
import { useSettingsStore } from '@/store/settingsStore';
import { speedUnitLabel } from '@/speed/format';
import { carLabel, formatLap, formatLastSeen, formatMph, shortUid } from '@/garage/format';
import { colors, fontSize, fontWeight, radius, spacing } from '@/theme/tokens';
import { colors, elevation, fontSize, fontWeight, radius, spacing } from '@/theme/tokens';
import { CarPhoto } from '@/catalog/CarPhoto';
import { useCarIdentity } from '@/catalog/useCarIdentity';

export default function CarDetailScreen() {
const insets = useSafeAreaInsets();
Expand All @@ -24,6 +26,7 @@ export default function CarDetailScreen() {
const car = useGarageStore((s) => s.cars.find((c) => c.uid === uid));
const rename = useGarageStore((s) => s.rename);
const onPortal = usePortalStore((s) => s.car?.uid === uid);
const identity = useCarIdentity(uid);
const speedUnit = useSettingsStore((s) => s.speedUnit);
const speedCalibration = useSettingsStore((s) => s.speedCalibration);
const speedDisplay = { unit: speedUnit, calibration: speedCalibration };
Expand Down Expand Up @@ -74,13 +77,51 @@ export default function CarDetailScreen() {
</View>
) : (
<>
<Text style={styles.title} numberOfLines={1}>
{carLabel(car)}
{identity ? (
<Link href={{ pathname: '/identify', params: { uid } }} asChild>
<Pressable style={({ pressed }) => [styles.heroPhoto, pressed && styles.pressed]}>
<CarPhoto
uri={identity.image}
width="100%"
aspectRatio={16 / 10}
rounded={radius.lg}
ring
/>
<View style={styles.changeBadge}>
<Text style={styles.changeBadgeText}>Change</Text>
</View>
</Pressable>
</Link>
) : null}

<Text style={styles.title} numberOfLines={2}>
{identity?.name ?? carLabel(car)}
</Text>
<Text style={styles.subtitle}>
{car.serial ? `Serial #${car.serial}` : 'No serial captured'} · {car.uid}
<Text style={styles.subtitle} numberOfLines={1}>
{identity
? [identity.series, identity.year ? String(identity.year) : null, identity.toyNumber]
.filter(Boolean)
.join(' · ') || 'Hot Wheels id'
: `${car.serial ? `Serial #${car.serial}` : 'No serial captured'} · ${car.uid}`}
</Text>

{!identity ? (
<Link href={{ pathname: '/identify', params: { uid } }} asChild>
<Pressable style={({ pressed }) => [styles.identityCard, pressed && styles.pressed]}>
<CarPhoto uri={null} size={64} rounded={radius.md} />
<View style={styles.identityText}>
<Text style={styles.identityName} numberOfLines={1}>
Unidentified car
</Text>
<Text style={styles.identityMeta} numberOfLines={2}>
Tap to match this tag to a real casting
</Text>
</View>
<Text style={styles.identityCta}>Identify</Text>
</Pressable>
</Link>
) : null}

<View style={styles.hero}>
<Text style={styles.heroValue}>{formatMph(car.bestMph, speedDisplay)}</Text>
<Text style={styles.heroUnit}>best scale {speedUnitLabel(speedUnit)}</Text>
Expand Down Expand Up @@ -148,14 +189,59 @@ const styles = StyleSheet.create({
onPortal: { color: colors.accent, fontSize: fontSize.sm, fontWeight: fontWeight.bold },
title: { color: colors.textPrimary, fontSize: fontSize.xl, fontWeight: fontWeight.heavy, marginTop: spacing(1) },
subtitle: { color: colors.textSecondary, fontSize: fontSize.sm },
hero: {
heroPhoto: {
marginTop: spacing(1),
borderRadius: radius.lg,
...elevation.card,
},
changeBadge: {
position: 'absolute',
top: spacing(2),
right: spacing(2),
backgroundColor: 'rgba(11,15,26,0.78)',
borderColor: colors.accent,
borderWidth: 1,
borderRadius: radius.pill,
paddingVertical: 4,
paddingHorizontal: spacing(3),
},
changeBadgeText: {
color: colors.accent,
fontSize: fontSize.xs,
fontWeight: fontWeight.heavy,
textTransform: 'uppercase',
letterSpacing: 1,
},
identityCard: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: radius.lg,
padding: spacing(3),
marginTop: spacing(2),
},
identityText: { flex: 1, gap: 2 },
identityName: { color: colors.textPrimary, fontSize: fontSize.md, fontWeight: fontWeight.bold },
identityMeta: { color: colors.textSecondary, fontSize: fontSize.sm },
identityCta: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: fontWeight.heavy,
textTransform: 'uppercase',
letterSpacing: 1,
},
hero: {
alignItems: 'center',
backgroundColor: colors.surfaceRaised,
borderColor: colors.accent,
borderWidth: 1,
borderRadius: radius.lg,
paddingVertical: spacing(5),
marginTop: spacing(2),
...elevation.accentGlow,
},
heroValue: { color: colors.accent, fontSize: fontSize.display, fontWeight: fontWeight.heavy },
heroUnit: { color: colors.textMuted, fontSize: fontSize.xs, textTransform: 'uppercase', letterSpacing: 1 },
Expand All @@ -170,6 +256,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing(3),
paddingHorizontal: spacing(4),
gap: 2,
...elevation.card,
},
statLabel: { color: colors.textMuted, fontSize: fontSize.xs, textTransform: 'uppercase', letterSpacing: 1 },
statValue: { color: colors.textPrimary, fontSize: fontSize.lg, fontWeight: fontWeight.bold },
Expand Down
Loading
Loading