feat(figgos): add card detail screens, collection views + gesture 3D flip

- Root layout: Stack navigator with GestureHandlerRootView
- Tab layout: Create, Shelf, Stack, Showcase tabs with NativeTabs
- Card V1: tap-to-flip with Reanimated spring animation
- Card V2: gesture-based 3D rotation (pan to rotate Y-axis, double-tap flip)
- Collection views: grid, shelf (by rarity), stack (deck), carousel (showcase)
- Card images: Detective Cole in 5 rarity variants (kraft, common, rare, epic, legendary)
- Back face: backstory, stats bars, rarity badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chr1st1anG 2026-02-11 15:16:07 +01:00
parent 95dd1d3e9e
commit 9462dfac43
18 changed files with 1228 additions and 60 deletions

View file

@ -0,0 +1,24 @@
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<NativeTabs tintColor="rgb(255, 204, 0)" backgroundColor="rgb(15, 15, 30)">
<NativeTabs.Trigger name="index">
<Icon sf="plus.circle.fill" drawable="add_circle" />
<Label>Create</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="carousel">
<Icon sf="sparkles.rectangle.stack.fill" drawable="view_carousel" />
<Label>Showcase</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="collection">
<Icon sf="square.grid.2x2.fill" drawable="grid_view" />
<Label>Grid</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="shelf" hidden />
<NativeTabs.Trigger name="stack" hidden />
<NativeTabs.Trigger name="neo-brutalist" hidden />
<NativeTabs.Trigger name="retro-pixel" hidden />
</NativeTabs>
);
}

View file

@ -0,0 +1,147 @@
import { useRef } from 'react';
import { View, Text, Image, Pressable, Animated, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { CARDS } from '../../data/cards';
import type { FigureRarity } from '@figgos/shared';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = SCREEN_WIDTH * 0.65;
const CARD_HEIGHT = CARD_WIDTH * 1.45;
const SPACING = 14;
const SIDE_SPACE = (SCREEN_WIDTH - CARD_WIDTH) / 2;
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
export default function CarouselScreen() {
const router = useRouter();
const scrollX = useRef(new Animated.Value(0)).current;
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View style={{ paddingHorizontal: 20, paddingTop: 24, paddingBottom: 16 }}>
<Text
className="text-foreground"
style={{ fontSize: 28, fontWeight: '900', letterSpacing: -0.5 }}
>
Showcase
</Text>
</View>
<View style={{ flex: 1, justifyContent: 'center' }}>
<Animated.FlatList
data={CARDS}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + SPACING}
decelerationRate="fast"
contentContainerStyle={{
paddingHorizontal: SIDE_SPACE,
alignItems: 'center',
}}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
useNativeDriver: true,
})}
renderItem={({ item, index }) => {
const inputRange = [
(index - 1) * (CARD_WIDTH + SPACING),
index * (CARD_WIDTH + SPACING),
(index + 1) * (CARD_WIDTH + SPACING),
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.85, 1, 0.85],
extrapolate: 'clamp',
});
const opacity = scrollX.interpolate({
inputRange,
outputRange: [0.5, 1, 0.5],
extrapolate: 'clamp',
});
const rotate = scrollX.interpolate({
inputRange,
outputRange: ['4deg', '0deg', '-4deg'],
extrapolate: 'clamp',
});
return (
<Animated.View
style={{
width: CARD_WIDTH,
marginRight: SPACING,
transform: [{ scale }, { rotate }],
opacity,
}}
>
<Pressable
onPress={() => router.push(`/card/v2/${item.id}` as any)}
className="active:opacity-90"
>
<View
style={{
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 14,
overflow: 'hidden',
}}
>
<Image
source={item.image}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
</Pressable>
</Animated.View>
);
}}
/>
</View>
<View className="flex-row items-center justify-center" style={{ paddingBottom: 24, gap: 8 }}>
{CARDS.map((card, i) => {
const inputRange = [
(i - 1) * (CARD_WIDTH + SPACING),
i * (CARD_WIDTH + SPACING),
(i + 1) * (CARD_WIDTH + SPACING),
];
const dotScale = scrollX.interpolate({
inputRange,
outputRange: [1, 1.4, 1],
extrapolate: 'clamp',
});
const dotOpacity = scrollX.interpolate({
inputRange,
outputRange: [0.3, 1, 0.3],
extrapolate: 'clamp',
});
return (
<Animated.View
key={card.id}
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: RARITY_COLORS[card.rarity],
transform: [{ scale: dotScale }],
opacity: dotOpacity,
}}
/>
);
})}
</View>
</SafeAreaView>
);
}

View file

@ -0,0 +1,61 @@
import { View, Text, Image, Pressable, FlatList, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { CARDS, type CardData } from '../../data/cards';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const GAP = 10;
const PADDING = 14;
const COLUMNS = 2;
const CARD_WIDTH = (SCREEN_WIDTH - PADDING * 2 - GAP * (COLUMNS - 1)) / COLUMNS;
const CARD_HEIGHT = CARD_WIDTH * 1.45;
function CardThumbnail({ card }: { card: CardData }) {
const router = useRouter();
return (
<Pressable
onPress={() => router.push(`/card/v2/${card.id}` as any)}
style={{ width: CARD_WIDTH }}
className="active:opacity-80"
>
<View
style={{
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 12,
overflow: 'hidden',
}}
>
<Image source={card.image} style={{ width: '100%', height: '100%' }} resizeMode="cover" />
</View>
</Pressable>
);
}
export default function CollectionScreen() {
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View style={{ paddingHorizontal: PADDING, paddingTop: 24, paddingBottom: 16 }}>
<Text
className="text-foreground"
style={{ fontSize: 28, fontWeight: '900', letterSpacing: -0.5 }}
>
Collection
</Text>
<Text className="text-muted-foreground" style={{ fontSize: 13, marginTop: 2 }}>
{CARDS.length} {CARDS.length === 1 ? 'Figgo' : 'Figgos'}
</Text>
</View>
<FlatList
data={CARDS}
numColumns={COLUMNS}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingHorizontal: PADDING, paddingBottom: 40 }}
columnWrapperStyle={{ gap: GAP, marginBottom: GAP }}
renderItem={({ item }) => <CardThumbnail card={item} />}
/>
</SafeAreaView>
);
}

View file

@ -0,0 +1,117 @@
import { View, Text, Image, Pressable, ScrollView, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { CARDS, type CardData } from '../../data/cards';
import type { FigureRarity } from '@figgos/shared';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = SCREEN_WIDTH * 0.32;
const CARD_HEIGHT = CARD_WIDTH * 1.45;
const OVERLAP = -16;
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
const RARITY_ORDER: FigureRarity[] = ['legendary', 'epic', 'rare', 'common'];
const RARITY_LABELS: Record<FigureRarity, string> = {
legendary: 'LEGENDARY',
epic: 'EPIC',
rare: 'RARE',
common: 'COMMON',
};
function ShelfRow({ rarity, cards }: { rarity: FigureRarity; cards: CardData[] }) {
const router = useRouter();
const color = RARITY_COLORS[rarity];
if (cards.length === 0) return null;
return (
<View style={{ marginBottom: 28 }}>
<Text
style={{
fontSize: 11,
fontWeight: '900',
color,
letterSpacing: 3,
textTransform: 'uppercase',
marginBottom: 10,
paddingHorizontal: 20,
}}
>
{RARITY_LABELS[rarity]}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 20 }}
>
{cards.map((card, i) => (
<Pressable
key={card.id}
onPress={() => router.push(`/card/v2/${card.id}` as any)}
className="active:opacity-80"
style={{ marginRight: i < cards.length - 1 ? OVERLAP : 0 }}
>
<View
style={{
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 10,
overflow: 'hidden',
}}
>
<Image
source={card.image}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
</Pressable>
))}
</ScrollView>
<View
style={{
height: 4,
marginHorizontal: 16,
marginTop: 6,
borderRadius: 2,
backgroundColor: color,
opacity: 0.25,
}}
/>
</View>
);
}
export default function ShelfScreen() {
const grouped = RARITY_ORDER.map((rarity) => ({
rarity,
cards: CARDS.filter((c) => c.rarity === rarity),
}));
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
<View style={{ paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }}>
<Text
className="text-foreground"
style={{ fontSize: 28, fontWeight: '900', letterSpacing: -0.5 }}
>
Shelf
</Text>
</View>
{grouped.map(({ rarity, cards }) => (
<ShelfRow key={rarity} rarity={rarity} cards={cards} />
))}
</ScrollView>
</SafeAreaView>
);
}

View file

@ -0,0 +1,191 @@
import { useState, useRef, useCallback } from 'react';
import { View, Text, Image, Pressable, Animated, Dimensions, PanResponder } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { CARDS } from '../../data/cards';
import type { FigureRarity } from '@figgos/shared';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = SCREEN_WIDTH * 0.72;
const CARD_HEIGHT = CARD_WIDTH * 1.45;
const VISIBLE_STACK = 4;
const SWIPE_THRESHOLD = 60;
const STACK_OFFSET = 22;
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
export default function StackScreen() {
const router = useRouter();
const [order, setOrder] = useState(() => CARDS.map((_, i) => i));
const [currentIndex, setCurrentIndex] = useState(0);
const swipeY = useRef(new Animated.Value(0)).current;
const isAnimating = useRef(false);
const dismissTop = useCallback(() => {
if (isAnimating.current) return;
isAnimating.current = true;
Animated.timing(swipeY, {
toValue: -500,
duration: 250,
useNativeDriver: true,
}).start(() => {
setOrder((prev) => [...prev.slice(1), prev[0]]);
setCurrentIndex((prev) => (prev + 1) % CARDS.length);
swipeY.setValue(0);
isAnimating.current = false;
});
}, [swipeY]);
const snapBack = useCallback(() => {
Animated.spring(swipeY, {
toValue: 0,
tension: 80,
friction: 10,
useNativeDriver: true,
}).start();
}, [swipeY]);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 15,
onPanResponderMove: (_, g) => {
if (g.dy < 0) {
swipeY.setValue(g.dy);
}
},
onPanResponderRelease: (_, g) => {
if (g.dy < -SWIPE_THRESHOLD || g.vy < -0.5) {
dismissTop();
} else {
snapBack();
}
},
})
).current;
const topCard = CARDS[order[0]];
const topOpacity = swipeY.interpolate({
inputRange: [-200, 0],
outputRange: [0.3, 1],
extrapolate: 'clamp',
});
// Total height needed: card + stack peek area
const stackHeight = CARD_HEIGHT + (VISIBLE_STACK - 1) * STACK_OFFSET;
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View style={{ paddingHorizontal: 20, paddingTop: 24, paddingBottom: 8 }}>
<Text
className="text-foreground"
style={{ fontSize: 28, fontWeight: '900', letterSpacing: -0.5 }}
>
Stack
</Text>
<Text className="text-muted-foreground" style={{ fontSize: 13, marginTop: 2 }}>
Swipe up to browse
</Text>
</View>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 20 }}>
<View style={{ width: CARD_WIDTH, height: stackHeight }}>
{/* Background cards — each peeks out below the one above */}
{order
.slice(1, VISIBLE_STACK)
.reverse()
.map((cardIdx, reverseI) => {
const depth = VISIBLE_STACK - 1 - reverseI; // 3, 2, 1
const card = CARDS[cardIdx];
const shrink = depth * 8; // each card slightly narrower
return (
<View
key={`bg-${depth}-${card.id}`}
style={{
position: 'absolute',
top: depth * STACK_OFFSET,
left: shrink / 2,
width: CARD_WIDTH - shrink,
height: CARD_HEIGHT,
opacity: 1 - depth * 0.15,
}}
>
<View
style={{
width: '100%',
height: '100%',
borderRadius: 14,
overflow: 'hidden',
}}
>
<Image
source={card.image}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
</View>
);
})}
{/* Top card */}
<Animated.View
{...panResponder.panHandlers}
style={{
position: 'absolute',
top: 0,
width: CARD_WIDTH,
height: CARD_HEIGHT,
transform: [{ translateY: swipeY }],
opacity: topOpacity,
}}
>
<Pressable
onPress={() => router.push(`/card/v2/${topCard.id}` as any)}
className="active:opacity-90"
style={{ width: '100%', height: '100%' }}
>
<View
style={{
width: '100%',
height: '100%',
borderRadius: 14,
overflow: 'hidden',
}}
>
<Image
source={topCard.image}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
</Pressable>
</Animated.View>
</View>
{/* Dot indicator */}
<View className="flex-row items-center" style={{ marginTop: 20, gap: 6 }}>
{CARDS.map((card, i) => (
<View
key={card.id}
style={{
width: i === currentIndex ? 20 : 8,
height: 8,
borderRadius: 4,
backgroundColor:
i === currentIndex ? RARITY_COLORS[card.rarity] : 'rgb(50, 50, 80)',
}}
/>
))}
</View>
</View>
</SafeAreaView>
);
}

View file

@ -1,20 +1,21 @@
import '../global.css';
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function TabLayout() {
export default function RootLayout() {
return (
<NativeTabs tintColor="rgb(255, 204, 0)" backgroundColor="rgb(15, 15, 30)">
<NativeTabs.Trigger name="index">
<Icon sf="plus.circle.fill" drawable="add_circle" />
<Label>Create</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="collection">
<Icon sf="square.grid.2x2.fill" drawable="grid_view" />
<Label>Collection</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="neo-brutalist" hidden />
<NativeTabs.Trigger name="retro-pixel" hidden />
</NativeTabs>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: 'rgb(15, 15, 30)' },
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="card/[id]" />
<Stack.Screen name="card/v2/[id]" />
</Stack>
</GestureHandlerRootView>
);
}

View file

@ -0,0 +1,279 @@
import { View, Text, Pressable, Image, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { CARDS } from '../../data/cards';
import type { FigureRarity } from '@figgos/shared';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = SCREEN_WIDTH - 48;
const CARD_HEIGHT = CARD_WIDTH * 1.5;
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
const STAT_COLORS = {
attack: 'rgb(255, 51, 102)',
defense: 'rgb(0, 210, 170)',
special: 'rgb(180, 130, 255)',
};
export default function CardDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const card = CARDS.find((c) => c.id === id);
const rotation = useSharedValue(0);
const isFlipped = useSharedValue(false);
const handleFlip = () => {
const target = isFlipped.value ? 0 : 180;
isFlipped.value = !isFlipped.value;
rotation.value = withTiming(target, {
duration: 600,
easing: Easing.bezier(0.4, 0, 0.2, 1),
});
};
const frontStyle = useAnimatedStyle(() => {
const rotateY = interpolate(rotation.value, [0, 180], [0, 180]);
return {
transform: [{ perspective: 1200 }, { rotateY: `${rotateY}deg` }],
backfaceVisibility: 'hidden' as const,
};
});
const backStyle = useAnimatedStyle(() => {
const rotateY = interpolate(rotation.value, [0, 180], [180, 360]);
return {
transform: [{ perspective: 1200 }, { rotateY: `${rotateY}deg` }],
backfaceVisibility: 'hidden' as const,
};
});
if (!card) {
return (
<SafeAreaView className="flex-1 bg-background items-center justify-center">
<Text className="text-foreground" style={{ fontSize: 16 }}>
Card not found
</Text>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
{/* Back button */}
<Pressable
onPress={() => router.back()}
className="active:opacity-70"
style={{ paddingHorizontal: 20, paddingVertical: 12 }}
>
<Text className="text-primary" style={{ fontSize: 16, fontWeight: '700' }}>
Back
</Text>
</Pressable>
<View className="flex-1 items-center justify-center">
<Pressable onPress={handleFlip}>
<View style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}>
{/* ── Front: just the image ── */}
<Animated.View
style={[
{
position: 'absolute',
width: CARD_WIDTH,
height: CARD_HEIGHT,
},
frontStyle,
]}
>
<Image
source={card.image}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
</Animated.View>
{/* ── Back ── */}
<Animated.View
style={[
{
position: 'absolute',
width: CARD_WIDTH,
height: CARD_HEIGHT,
},
backStyle,
]}
>
{/* Shadow layer */}
<View
style={{
position: 'absolute',
top: 6,
left: 6,
right: -6,
bottom: -6,
borderRadius: 16,
backgroundColor: RARITY_COLORS[card.rarity],
opacity: 0.4,
}}
/>
{/* Card back */}
<View
className="bg-surface rounded-2xl"
style={{
flex: 1,
borderWidth: 3,
borderColor: RARITY_COLORS[card.rarity],
padding: 24,
justifyContent: 'space-between',
}}
>
{/* Header */}
<View>
<View
className="bg-secondary rounded self-start mb-3"
style={{
paddingHorizontal: 12,
paddingVertical: 3,
transform: [{ rotate: '-2deg' }],
}}
>
<Text
className="text-secondary-foreground"
style={{
fontSize: 10,
fontWeight: '900',
letterSpacing: 2,
textTransform: 'uppercase',
}}
>
Backstory
</Text>
</View>
<Text
className="text-foreground"
style={{ fontSize: 22, fontWeight: '900', letterSpacing: -0.5 }}
>
{card.name}
</Text>
<Text
style={{
fontSize: 11,
fontWeight: '800',
letterSpacing: 2,
textTransform: 'uppercase',
marginTop: 2,
color: RARITY_COLORS[card.rarity],
}}
>
{card.subtitle}
</Text>
</View>
{/* Description */}
<View>
<Text className="text-muted-foreground" style={{ fontSize: 14, lineHeight: 22 }}>
{card.description}
</Text>
</View>
{/* Stats */}
<View>
<Text
className="mb-2"
style={{
fontSize: 11,
fontWeight: '900',
letterSpacing: 3,
textTransform: 'uppercase',
color: RARITY_COLORS[card.rarity],
}}
>
Stats
</Text>
<StatBar label="ATK" value={card.stats.attack} color={STAT_COLORS.attack} />
<StatBar label="DEF" value={card.stats.defense} color={STAT_COLORS.defense} />
<StatBar label="SPL" value={card.stats.special} color={STAT_COLORS.special} />
</View>
{/* Bottom: rarity + ID */}
<View className="flex-row items-center justify-between">
<View
className="rounded-full"
style={{
paddingHorizontal: 12,
paddingVertical: 4,
backgroundColor: RARITY_COLORS[card.rarity],
}}
>
<Text
style={{
fontSize: 10,
fontWeight: '900',
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgb(15, 15, 30)',
}}
>
{card.rarity}
</Text>
</View>
<Text
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: '600', letterSpacing: 1 }}
>
#{card.id.split('-').pop()?.toUpperCase()}
</Text>
</View>
</View>
</Animated.View>
</View>
</Pressable>
</View>
</SafeAreaView>
);
}
function StatBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View className="flex-row items-center mb-2" style={{ gap: 8 }}>
<Text
className="text-muted-foreground"
style={{ fontSize: 11, fontWeight: '900', width: 28, letterSpacing: 1 }}
>
{label}
</Text>
<View
className="flex-1 bg-input rounded-full"
style={{ height: 10, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
>
<View
style={{
width: `${value}%`,
height: '100%',
backgroundColor: color,
borderRadius: 999,
}}
/>
</View>
<Text
className="text-foreground"
style={{ fontSize: 11, fontWeight: '800', width: 24, textAlign: 'right' }}
>
{value}
</Text>
</View>
);
}

View file

@ -0,0 +1,329 @@
import { useState } from 'react';
import { View, Text, Pressable, Image, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { CARDS } from '../../../data/cards';
import type { FigureRarity } from '@figgos/shared';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CONTAINER_WIDTH = SCREEN_WIDTH - 48;
const CONTAINER_HEIGHT = CONTAINER_WIDTH * 1.5;
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
const STAT_COLORS = {
attack: 'rgb(255, 51, 102)',
defense: 'rgb(0, 210, 170)',
special: 'rgb(180, 130, 255)',
};
const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.8 };
export default function CardDetailV2Screen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const card = CARDS.find((c) => c.id === id);
// Track the actual rendered image size
const [imageSize, setImageSize] = useState({ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT });
const handleImageLayout = (e: { nativeEvent: { layout: { width: number; height: number } } }) => {
// Image uses contain, so we can read the actual layout
// But we need the source dimensions to compute the real rendered area
};
// Use Image.resolveAssetSource to get original dimensions, then compute contain size
const computeContainSize = (srcW: number, srcH: number) => {
const ratio = Math.min(CONTAINER_WIDTH / srcW, CONTAINER_HEIGHT / srcH);
setImageSize({
width: Math.round(srcW * ratio),
height: Math.round(srcH * ratio),
});
};
// Rotation around vertical axis only
const rotateY = useSharedValue(0);
const savedRotateY = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => {
rotateY.value = savedRotateY.value + e.translationX * 0.5;
})
.onEnd(() => {
const normalised = ((rotateY.value % 360) + 360) % 360;
let target: number;
if (normalised < 90) {
target = 0;
} else if (normalised < 270) {
target = 180;
} else {
target = 360;
}
const diff = target - normalised;
const snapTo = rotateY.value + diff;
savedRotateY.value = snapTo % 360;
rotateY.value = withSpring(snapTo, SPRING_CONFIG);
});
// Double tap to do a full flip
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
const target = savedRotateY.value + 180;
savedRotateY.value = target % 360;
rotateY.value = withSpring(target, { damping: 18, stiffness: 150, mass: 1 });
});
const composed = Gesture.Race(doubleTap, pan);
const frontStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1200 }, { rotateY: `${rotateY.value}deg` }],
backfaceVisibility: 'hidden' as const,
}));
const backStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1200 }, { rotateY: `${rotateY.value + 180}deg` }],
backfaceVisibility: 'hidden' as const,
}));
if (!card) {
return (
<SafeAreaView className="flex-1 bg-background items-center justify-center">
<Text className="text-foreground" style={{ fontSize: 16 }}>
Card not found
</Text>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
{/* Back button */}
<View
className="flex-row items-center justify-between"
style={{ paddingHorizontal: 20, paddingVertical: 12 }}
>
<Pressable onPress={() => router.back()} className="active:opacity-70">
<Text className="text-primary" style={{ fontSize: 16, fontWeight: '700' }}>
Back
</Text>
</Pressable>
<Text className="text-muted-foreground" style={{ fontSize: 12, fontWeight: '600' }}>
V2 Gesture 3D
</Text>
</View>
<View className="flex-1 items-center justify-center">
<GestureDetector gesture={composed}>
<View style={{ width: imageSize.width, height: imageSize.height }}>
{/* ── Front: just the image ── */}
<Animated.View
style={[
{
position: 'absolute',
width: imageSize.width,
height: imageSize.height,
},
frontStyle,
]}
>
<Image
source={card.image}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
onLoad={(e) => {
const { width: srcW, height: srcH } = e.nativeEvent.source;
computeContainSize(srcW, srcH);
}}
/>
</Animated.View>
{/* ── Back ── */}
<Animated.View
style={[
{
position: 'absolute',
width: imageSize.width,
height: imageSize.height,
},
backStyle,
]}
>
{/* Shadow layer */}
<View
style={{
position: 'absolute',
top: 5,
left: 5,
right: -5,
bottom: -5,
borderRadius: 16,
backgroundColor: RARITY_COLORS[card.rarity],
opacity: 0.3,
}}
/>
{/* Card back */}
<View
className="bg-surface rounded-2xl"
style={{
flex: 1,
borderWidth: 3,
borderColor: RARITY_COLORS[card.rarity],
padding: 20,
justifyContent: 'space-between',
}}
>
{/* Header */}
<View>
<View
className="bg-secondary rounded self-start mb-2"
style={{
paddingHorizontal: 10,
paddingVertical: 2,
transform: [{ rotate: '-2deg' }],
}}
>
<Text
className="text-secondary-foreground"
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 2,
textTransform: 'uppercase',
}}
>
Backstory
</Text>
</View>
<Text
className="text-foreground"
style={{ fontSize: 20, fontWeight: '900', letterSpacing: -0.5 }}
>
{card.name}
</Text>
<Text
style={{
fontSize: 10,
fontWeight: '800',
letterSpacing: 2,
textTransform: 'uppercase',
marginTop: 2,
color: RARITY_COLORS[card.rarity],
}}
>
{card.subtitle}
</Text>
</View>
{/* Description */}
<Text
className="text-muted-foreground"
style={{ fontSize: 13, lineHeight: 20 }}
numberOfLines={5}
>
{card.description}
</Text>
{/* Stats */}
<View>
<Text
style={{
fontSize: 10,
fontWeight: '900',
letterSpacing: 3,
textTransform: 'uppercase',
color: RARITY_COLORS[card.rarity],
marginBottom: 6,
}}
>
Stats
</Text>
<StatBar label="ATK" value={card.stats.attack} color={STAT_COLORS.attack} />
<StatBar label="DEF" value={card.stats.defense} color={STAT_COLORS.defense} />
<StatBar label="SPL" value={card.stats.special} color={STAT_COLORS.special} />
</View>
{/* Bottom: rarity + ID */}
<View className="flex-row items-center justify-between">
<View
className="rounded-full"
style={{
paddingHorizontal: 10,
paddingVertical: 3,
backgroundColor: RARITY_COLORS[card.rarity],
}}
>
<Text
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgb(15, 15, 30)',
}}
>
{card.rarity}
</Text>
</View>
<Text
className="text-muted-foreground"
style={{ fontSize: 9, fontWeight: '600', letterSpacing: 1 }}
>
#{card.id.split('-').pop()?.toUpperCase()}
</Text>
</View>
</View>
</Animated.View>
</View>
</GestureDetector>
{/* Hint */}
<Text
className="text-muted-foreground mt-6"
style={{ fontSize: 11, fontWeight: '600', letterSpacing: 1 }}
>
Drag to rotate · Double-tap to flip
</Text>
</View>
</SafeAreaView>
);
}
function StatBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View className="flex-row items-center mb-1.5" style={{ gap: 6 }}>
<Text
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: '900', width: 26, letterSpacing: 1 }}
>
{label}
</Text>
<View
className="flex-1 bg-input rounded-full"
style={{ height: 8, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
>
<View
style={{
width: `${value}%`,
height: '100%',
backgroundColor: color,
borderRadius: 999,
}}
/>
</View>
<Text
className="text-foreground"
style={{ fontSize: 10, fontWeight: '800', width: 22, textAlign: 'right' }}
>
{value}
</Text>
</View>
);
}

View file

@ -1,46 +0,0 @@
import { View, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function CollectionScreen() {
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-1 items-center justify-center px-8">
{/* Empty state card */}
<View style={{ position: 'relative' }}>
<View
className="bg-primary-dark rounded-lg"
style={{ position: 'absolute', top: 5, left: 5, right: -5, bottom: -5 }}
/>
<View
className="bg-surface rounded-lg items-center"
style={{
borderWidth: 3,
borderColor: 'rgb(255, 204, 0)',
paddingHorizontal: 32,
paddingVertical: 32,
}}
>
<View
className="bg-input rounded-lg items-center justify-center mb-4"
style={{ width: 56, height: 56, borderWidth: 2, borderColor: 'rgb(50, 50, 80)' }}
>
<Text style={{ fontSize: 24 }}>📦</Text>
</View>
<Text
className="text-foreground"
style={{ fontSize: 18, fontWeight: '900', letterSpacing: -0.3 }}
>
No figures yet
</Text>
<Text
className="text-muted-foreground text-center mt-2"
style={{ fontSize: 14, lineHeight: 20 }}
>
Create your first Figgo{'\n'}to start your collection.
</Text>
</View>
</View>
</View>
</SafeAreaView>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,65 @@
import type { ImageSourcePropType } from 'react-native';
import type { FigureRarity } from '@figgos/shared';
export interface CardData {
id: string;
name: string;
subtitle: string;
description: string;
rarity: FigureRarity;
image: ImageSourcePropType;
stats: { attack: number; defense: number; special: number };
}
export const CARDS: CardData[] = [
{
id: 'cole-epic',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'A hardboiled detective who has seen it all. Armed with nothing but a trench coat, a sharp mind, and an unhealthy coffee addiction. Solves impossible cases in the rain-soaked streets of Noir City.',
rarity: 'epic',
image: require('../assets/images/cole-epic.png'),
stats: { attack: 42, defense: 68, special: 75 },
},
{
id: 'cole-rare',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'Fresh off his first big case, Cole is making a name for himself in the precinct. His instincts are sharp, but he still has a lot to learn about the darker side of Noir City.',
rarity: 'rare',
image: require('../assets/images/cole-rare.png'),
stats: { attack: 35, defense: 52, special: 60 },
},
{
id: 'cole-legendary',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'The legend of Noir City. After decades on the force, Cole has become the detective other detectives tell stories about. His case closure rate is unmatched in the history of the division.',
rarity: 'legendary',
image: require('../assets/images/cole-legendary.png'),
stats: { attack: 78, defense: 85, special: 95 },
},
{
id: 'cole-common',
name: 'Detective Cole',
subtitle: 'Noir City Homicide Division',
description:
'A standard-issue detective doing his best in a tough city. Nothing fancy, but reliable. Shows up every day, drinks too much coffee, and gets the job done.',
rarity: 'common',
image: require('../assets/images/cole-common.png'),
stats: { attack: 22, defense: 30, special: 28 },
},
{
id: 'cole-kraft',
name: 'Detective Cole',
subtitle: 'Kraft Edition',
description:
"Limited kraft paper edition. A collector's item with a vintage feel. The same old Cole, but with that handmade, artisanal charm that cardboard enthusiasts crave.",
rarity: 'common',
image: require('../assets/images/cole-kraft.png'),
stats: { attack: 25, defense: 32, special: 30 },
},
];