mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:41:10 +02:00
✨ 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:
parent
95dd1d3e9e
commit
9462dfac43
18 changed files with 1228 additions and 60 deletions
24
apps/figgos/apps/mobile/app/(tabs)/_layout.tsx
Normal file
24
apps/figgos/apps/mobile/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
apps/figgos/apps/mobile/app/(tabs)/carousel.tsx
Normal file
147
apps/figgos/apps/mobile/app/(tabs)/carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/figgos/apps/mobile/app/(tabs)/collection.tsx
Normal file
61
apps/figgos/apps/mobile/app/(tabs)/collection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
apps/figgos/apps/mobile/app/(tabs)/shelf.tsx
Normal file
117
apps/figgos/apps/mobile/app/(tabs)/shelf.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
apps/figgos/apps/mobile/app/(tabs)/stack.tsx
Normal file
191
apps/figgos/apps/mobile/app/(tabs)/stack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
279
apps/figgos/apps/mobile/app/card/[id].tsx
Normal file
279
apps/figgos/apps/mobile/app/card/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
329
apps/figgos/apps/mobile/app/card/v2/[id].tsx
Normal file
329
apps/figgos/apps/mobile/app/card/v2/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
BIN
apps/figgos/apps/mobile/assets/images/cole-common.png
Normal file
BIN
apps/figgos/apps/mobile/assets/images/cole-common.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 912 KiB |
BIN
apps/figgos/apps/mobile/assets/images/cole-epic.png
Normal file
BIN
apps/figgos/apps/mobile/assets/images/cole-epic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/figgos/apps/mobile/assets/images/cole-kraft.png
Normal file
BIN
apps/figgos/apps/mobile/assets/images/cole-kraft.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 943 KiB |
BIN
apps/figgos/apps/mobile/assets/images/cole-legendary.png
Normal file
BIN
apps/figgos/apps/mobile/assets/images/cole-legendary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/figgos/apps/mobile/assets/images/cole-rare.png
Normal file
BIN
apps/figgos/apps/mobile/assets/images/cole-rare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
65
apps/figgos/apps/mobile/data/cards.ts
Normal file
65
apps/figgos/apps/mobile/data/cards.ts
Normal 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 },
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue