first commit

This commit is contained in:
glitchySid
2026-06-24 18:19:23 +05:30
parent c48d7dba22
commit 8ef007a8d1
16 changed files with 1329 additions and 111 deletions

View File

@@ -0,0 +1,32 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, View } from 'react-native';
import { Brand } from '@/constants/theme';
/**
* Stack layout for the gumble auth / onboarding flow. The header is hidden
* and the background is forced to the brand dark, so the flow always renders
* in its intended dark theme regardless of the OS color scheme.
*/
export default function AuthLayout() {
return (
<View style={styles.root}>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: Brand.bg },
animation: 'slide_from_right',
}}
/>
<StatusBar style="light" />
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: Brand.bg,
},
});

283
src/app/(auth)/login.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrandPressable } from '@/components/brand-pressable';
import { BrandTextInput } from '@/components/brand-text-input';
import { Brand, BrandFont } from '@/constants/theme';
/**
* Frame `61-5` in the Figma ("Login"). The hero art in the Figma is replaced
* with a magenta gradient placeholder; the rest of the form — title, tabs,
* inputs, social row — is reproduced.
*/
export default function LoginScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [tab, setTab] = useState<'signup' | 'login'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.hero}>
<LinearGradient
colors={['#5a0a3a', '#3a0a25', Brand.bg]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<HeroSilhouette />
</View>
<View style={styles.body}>
<View style={styles.titleBlock}>
<Text style={styles.title}>Welcome back!</Text>
<Text style={styles.subtitle}>Log in to your account</Text>
</View>
<View style={styles.tabs}>
<Pressable onPress={() => setTab('signup')} style={styles.tab}>
<Text
style={[
styles.tabLabel,
tab === 'signup' ? styles.tabLabelActive : styles.tabLabelInactive,
]}>
Sign Up
</Text>
{tab === 'signup' ? <View style={styles.tabIndicator} /> : null}
</Pressable>
<Pressable onPress={() => setTab('login')} style={styles.tab}>
<Text
style={[
styles.tabLabel,
tab === 'login' ? styles.tabLabelActive : styles.tabLabelInactive,
]}>
Log In
</Text>
{tab === 'login' ? <View style={styles.tabIndicator} /> : null}
</Pressable>
</View>
<View style={styles.form}>
<BrandTextInput
placeholder="Email"
placeholderTextColor={Brand.textFaint}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
leadingIcon="envelope"
/>
<BrandTextInput
placeholder="Password"
placeholderTextColor={Brand.textFaint}
secureToggle
value={password}
onChangeText={setPassword}
leadingIcon="lock"
/>
</View>
<Pressable style={styles.forgotRow}>
<Text style={styles.forgotText}>Forgot password?</Text>
</Pressable>
<BrandPressable
label={tab === 'login' ? 'Log In' : 'Create Account'}
onPress={() => router.push('/(auth)/select-games')}
/>
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or continue with</Text>
<View style={styles.dividerLine} />
</View>
<View style={styles.socialRow}>
<SocialButton label="Apple" />
<SocialButton label="Google" />
<SocialButton label="Facebook" />
</View>
</View>
</View>
);
}
function HeroSilhouette() {
return (
<View style={silStyles.root}>
<View style={silStyles.ring} />
<View style={silStyles.disc} />
<View style={[silStyles.orb, silStyles.orbA]} />
<View style={[silStyles.orb, silStyles.orbB]} />
</View>
);
}
function SocialButton({ label }: { label: string }) {
return (
<Pressable accessibilityRole="button" style={({ pressed }) => [styles.socialButton, pressed && styles.socialPressed]}>
<Text style={styles.socialLabel}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: Brand.bg,
},
hero: {
height: 220,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
body: {
flex: 1,
paddingHorizontal: 24,
paddingTop: 24,
gap: 16,
},
titleBlock: {
gap: 4,
},
title: {
color: Brand.text,
fontSize: 28,
lineHeight: 34,
fontFamily: BrandFont.montserratBold,
},
subtitle: {
color: Brand.textMuted,
fontSize: 14,
lineHeight: 20,
fontFamily: BrandFont.poppinsRegular,
},
tabs: {
flexDirection: 'row',
gap: 24,
paddingTop: 8,
},
tab: {
paddingBottom: 8,
},
tabLabel: {
fontSize: 15,
lineHeight: 20,
fontFamily: BrandFont.poppinsSemiBold,
},
tabLabelActive: {
color: Brand.text,
},
tabLabelInactive: {
color: Brand.textMuted,
},
tabIndicator: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: Brand.accent,
borderRadius: 1,
},
form: {
gap: 12,
marginTop: 8,
},
forgotRow: {
alignSelf: 'flex-end',
paddingVertical: 4,
},
forgotText: {
color: Brand.accent,
fontSize: 13,
lineHeight: 18,
fontFamily: BrandFont.poppinsMedium,
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: 4,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: Brand.border,
},
dividerText: {
color: Brand.textMuted,
fontSize: 12,
lineHeight: 16,
fontFamily: BrandFont.poppinsRegular,
},
socialRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 16,
marginTop: 4,
},
socialButton: {
width: 52,
height: 52,
borderRadius: 26,
borderWidth: 1,
borderColor: Brand.border,
backgroundColor: Brand.surface,
alignItems: 'center',
justifyContent: 'center',
},
socialPressed: {
opacity: 0.7,
},
socialLabel: {
color: Brand.text,
fontSize: 12,
lineHeight: 16,
fontFamily: BrandFont.poppinsSemiBold,
},
});
const silStyles = StyleSheet.create({
root: {
width: 200,
height: 200,
alignItems: 'center',
justifyContent: 'center',
},
ring: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.2)',
},
disc: {
width: 110,
height: 110,
borderRadius: 55,
backgroundColor: 'rgba(255,255,255,0.06)',
},
orb: {
position: 'absolute',
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: Brand.accent,
},
orbA: {
top: 30,
left: 50,
},
orbB: {
bottom: 40,
right: 40,
backgroundColor: '#f3c',
},
});

View File

@@ -0,0 +1,206 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrandPressable } from '@/components/brand-pressable';
import { Brand, BrandFont } from '@/constants/theme';
/**
* Frame `52-54` in the Figma ("UI variation - 2"). The opening splash that
* introduces Gumble and offers Sign Up / Log In. The hero artwork in the
* Figma is a custom illustration that we approximate with a magenta-to-pink
* gradient and a stylised game-pad silhouette drawn from primitives.
*/
export default function OnboardingScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
return (
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.header}>
<View style={styles.logoMark}>
<Text style={styles.logoMarkText}>G</Text>
</View>
<Text style={styles.brandWordmark}>Gumble</Text>
</View>
<View style={styles.hero}>
<View style={styles.heroFrame}>
<LinearGradient
colors={['rgba(233,46,186,0.18)', 'rgba(255,255,255,0.02)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<GamePadIllustration />
</View>
</View>
<View style={styles.copy}>
<Text style={styles.title}>Gamble your games, gamble your skills</Text>
<Text style={styles.subtitle}>
Play mini-games, complete tasks, and earn rewards all powered by your favorite games.
</Text>
</View>
<View style={styles.actions}>
<BrandPressable label="Sign Up" onPress={() => router.push('/(auth)/login')} />
<BrandPressable
label="Log In"
variant="outline"
onPress={() => router.push('/(auth)/login')}
/>
</View>
</View>
);
}
function GamePadIllustration() {
return (
<View style={illustStyles.root}>
<View style={illustStyles.pad}>
<View style={illustStyles.dpad}>
<View style={[illustStyles.dpadArm, illustStyles.dpadHorizontal]} />
<View style={[illustStyles.dpadArm, illustStyles.dpadVertical]} />
</View>
<View style={illustStyles.buttons}>
<View style={[illustStyles.button, { backgroundColor: '#e92eba' }]} />
<View style={[illustStyles.button, { backgroundColor: '#f3c' }]} />
</View>
<View style={illustStyles.stick} />
</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: Brand.bg,
paddingHorizontal: 24,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingTop: 12,
},
logoMark: {
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: Brand.accent,
alignItems: 'center',
justifyContent: 'center',
},
logoMarkText: {
color: Brand.text,
fontSize: 16,
lineHeight: 20,
fontFamily: BrandFont.montserratBold,
},
brandWordmark: {
color: Brand.accent,
fontSize: 18,
lineHeight: 22,
fontFamily: BrandFont.montserratBold,
},
hero: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 16,
},
heroFrame: {
width: '100%',
aspectRatio: 1,
maxHeight: 320,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: Brand.surfaceAlt,
alignItems: 'center',
justifyContent: 'center',
},
copy: {
gap: 12,
paddingBottom: 24,
},
title: {
color: Brand.text,
fontSize: 26,
lineHeight: 32,
fontFamily: BrandFont.montserratBold,
textAlign: 'center',
},
subtitle: {
color: Brand.textMuted,
fontSize: 14,
lineHeight: 20,
fontFamily: BrandFont.poppinsRegular,
textAlign: 'center',
paddingHorizontal: 8,
},
actions: {
gap: 12,
paddingBottom: 8,
},
});
const illustStyles = StyleSheet.create({
root: {
width: '70%',
aspectRatio: 1.2,
alignItems: 'center',
justifyContent: 'center',
},
pad: {
width: '100%',
height: '60%',
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 24,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 24,
},
dpad: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
dpadArm: {
position: 'absolute',
backgroundColor: 'rgba(255,255,255,0.5)',
borderRadius: 3,
},
dpadHorizontal: {
width: 44,
height: 10,
},
dpadVertical: {
width: 10,
height: 44,
},
buttons: {
flexDirection: 'row',
gap: 12,
},
button: {
width: 18,
height: 18,
borderRadius: 9,
},
stick: {
position: 'absolute',
bottom: 14,
alignSelf: 'center',
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: 'rgba(255,255,255,0.3)',
},
});

View File

@@ -0,0 +1,117 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrandTextInput } from '@/components/brand-text-input';
import { GameTile } from '@/components/game-tile';
import { Brand, BrandFont } from '@/constants/theme';
import { useGameSearch } from '@/hooks/use-game-search';
/**
* Frames `24-101` (empty search + Most Popular), `29-3` ("Pubg|" + Most
* Popular), and `29-37` ("Pubg|" + Searched Results). All three are the same
* screen with different local search state; the section header swaps based on
* whether the query has results that aren't the popular set.
*/
export default function SearchGamesScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [query, setQuery] = useState('');
const results = useGameSearch(query);
const isEmpty = query.trim().length === 0;
const sectionTitle = isEmpty ? 'Most Popular' : 'Searched Results';
return (
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.searchRow}>
<BrandTextInput
autoFocus
placeholder="Search games by name"
value={query}
onChangeText={setQuery}
leadingIcon="magnifyingglass"
returnKeyType="search"
autoCapitalize="none"
/>
</View>
<View style={styles.headerRow}>
<Text style={styles.sectionTitle}>{sectionTitle}</Text>
<Text onPress={() => router.back()} style={styles.cancel}>
Cancel
</Text>
</View>
<ScrollView
contentContainerStyle={styles.grid}
showsVerticalScrollIndicator={false}>
{results.length === 0 ? (
<Text style={styles.empty}>No games match {query}.</Text>
) : (
results.map((game) => (
<GameTile
key={game.id}
id={game.id}
name={game.name}
genre={game.genre}
gradient={game.gradient}
onPress={() => {
// Demo: a tap on a result just clears the query so the user
// can try another search. Real apps would add the game.
setQuery('');
}}
/>
))
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: Brand.bg,
paddingHorizontal: 24,
},
searchRow: {
paddingTop: 8,
paddingBottom: 12,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 12,
},
sectionTitle: {
color: Brand.text,
fontSize: 18,
lineHeight: 24,
fontFamily: BrandFont.poppinsSemiBold,
},
cancel: {
color: Brand.textMuted,
fontSize: 13,
lineHeight: 18,
fontFamily: BrandFont.poppinsMedium,
padding: 4,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
paddingBottom: 24,
rowGap: 16,
},
empty: {
color: Brand.textMuted,
fontSize: 14,
lineHeight: 20,
fontFamily: BrandFont.poppinsRegular,
paddingTop: 32,
textAlign: 'center',
alignSelf: 'center',
},
});

View File

@@ -0,0 +1,116 @@
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrandPressable } from '@/components/brand-pressable';
import { GameTile } from '@/components/game-tile';
import { Brand, BrandFont } from '@/constants/theme';
import { GAMES } from '@/data/games';
/**
* Frames `20-21` (initial, nothing selected) and `24-46` (2 games selected).
* The two Figma frames are the same screen with different local state, so we
* model them as a single screen with a `Set<string>` of selected ids.
*/
export default function SelectGamesScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
return (
<View style={[styles.root, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.header}>
<Text style={styles.title}>Which games do you play?</Text>
<Text style={styles.subtitle}>
Pick the games you play the most to personalize your experience.
</Text>
</View>
<ScrollView
contentContainerStyle={styles.grid}
showsVerticalScrollIndicator={false}>
{GAMES.map((game) => (
<GameTile
key={game.id}
id={game.id}
name={game.name}
genre={game.genre}
gradient={game.gradient}
selected={selected.has(game.id)}
onPress={() => toggle(game.id)}
/>
))}
</ScrollView>
<View style={styles.footer}>
<BrandPressable
label={`Continue${selected.size > 0 ? ` (${selected.size})` : ''}`}
disabled={selected.size === 0}
onPress={() => router.push('/(auth)/search-games')}
/>
<Text
onPress={() => router.push('/(auth)/search-games')}
style={styles.link}>
Search for a game
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: Brand.bg,
paddingHorizontal: 24,
},
header: {
paddingTop: 16,
paddingBottom: 16,
gap: 6,
},
title: {
color: Brand.text,
fontSize: 22,
lineHeight: 28,
fontFamily: BrandFont.montserratBold,
},
subtitle: {
color: Brand.textMuted,
fontSize: 13,
lineHeight: 18,
fontFamily: BrandFont.poppinsRegular,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
paddingVertical: 8,
rowGap: 16,
},
footer: {
paddingTop: 16,
gap: 8,
alignItems: 'center',
},
link: {
color: Brand.accent,
fontSize: 13,
lineHeight: 18,
fontFamily: BrandFont.poppinsMedium,
paddingVertical: 6,
},
});

View File

@@ -1,15 +1,52 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
import { useColorScheme } from 'react-native';
import {
Montserrat_400Regular,
Montserrat_500Medium,
Montserrat_600SemiBold,
Montserrat_700Bold,
useFonts as useMontserratFonts,
} from "@expo-google-fonts/montserrat";
import {
Poppins_400Regular,
Poppins_500Medium,
Poppins_600SemiBold,
Poppins_700Bold,
useFonts as usePoppinsFonts,
} from "@expo-google-fonts/poppins";
import { DarkTheme, DefaultTheme, ThemeProvider, Stack } from "expo-router";
import { useColorScheme } from "react-native";
import { AnimatedSplashOverlay } from '@/components/animated-icon';
import AppTabs from '@/components/app-tabs';
import { AnimatedSplashOverlay } from "@/components/animated-icon";
export default function TabLayout() {
export default function RootLayout() {
const colorScheme = useColorScheme();
const [poppinsLoaded] = usePoppinsFonts({
Poppins_400Regular,
Poppins_500Medium,
Poppins_600SemiBold,
Poppins_700Bold,
});
const [montserratLoaded] = useMontserratFonts({
Montserrat_400Regular,
Montserrat_500Medium,
Montserrat_600SemiBold,
Montserrat_700Bold,
});
if (!poppinsLoaded || !montserratLoaded) {
return null;
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<AnimatedSplashOverlay />
<AppTabs />
<Stack
screenOptions={{
headerShown: false,
}}
/>
</ThemeProvider>
);
}

View File

@@ -1,98 +1,14 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEffect } from "react";
import { useRouter } from "expo-router";
import { AnimatedIcon } from '@/components/animated-icon';
import { HintRow } from '@/components/hint-row';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
// Redirect the root route to the onboarding flow on app start.
export default function IndexRedirect() {
const router = useRouter();
useEffect(() => {
// Use replace so the welcome route doesn't remain in the navigation stack.
router.replace("/(auth)/onboarding");
}, [router]);
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
);
// Nothing to render here while we redirect.
return null;
}
export default function HomeScreen() {
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
flexDirection: 'row',
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: 'center',
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
textAlign: 'center',
},
code: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
},
});

View File

@@ -0,0 +1,108 @@
import { LinearGradient } from 'expo-linear-gradient';
import { Pressable, StyleSheet, Text, type StyleProp, type ViewStyle } from 'react-native';
import { Brand, BrandFont } from '@/constants/theme';
type Variant = 'solid' | 'outline';
export type BrandPressableProps = {
label: string;
onPress?: () => void;
variant?: Variant;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
};
/**
* Full-width pill button used throughout the gumble flow. The `solid` variant
* uses the magenta→pink brand gradient. The `outline` variant renders an
* outlined button on a dark background, used for the secondary "Log In" call
* to action on the onboarding screen.
*/
export function BrandPressable({
label,
onPress,
variant = 'solid',
disabled = false,
style,
}: BrandPressableProps) {
if (variant === 'outline') {
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.base,
styles.outline,
disabled && styles.disabled,
pressed && styles.pressed,
style,
]}>
<Text style={[styles.label, styles.outlineLabel]}>{label}</Text>
</Pressable>
);
}
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.base,
disabled && styles.disabled,
pressed && styles.pressed,
style,
]}>
<LinearGradient
colors={[...Brand.gradient]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradient}>
<Text style={[styles.label, styles.solidLabel]}>{label}</Text>
</LinearGradient>
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
height: 55,
borderRadius: 8,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'stretch',
},
gradient: {
flex: 1,
width: '100%',
justifyContent: 'center',
alignItems: 'center',
},
label: {
fontSize: 16,
lineHeight: 22,
fontFamily: BrandFont.poppinsSemiBold,
},
solidLabel: {
color: Brand.text,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: Brand.border,
},
outlineLabel: {
color: Brand.text,
},
pressed: {
opacity: 0.8,
},
disabled: {
opacity: 0.4,
},
});

View File

@@ -0,0 +1,107 @@
import { SymbolView } from 'expo-symbols';
import { useState } from 'react';
import {
Pressable,
StyleSheet,
TextInput,
View,
type TextInputProps,
} from 'react-native';
import { Brand, BrandFont } from '@/constants/theme';
export type BrandTextInputProps = Omit<TextInputProps, 'style'> & {
/** SF Symbol name to render on the leading edge. */
leadingIcon?: string;
/** When true, renders a show/hide eye toggle on the right edge. */
secureToggle?: boolean;
/** Optional helper text rendered below the input. */
helperText?: string;
};
/**
* Themed dark text input used for email, password, and the search bar. The
* secureToggle is for password fields; everything else uses the same
* `BrandTextInput` so the styling stays consistent.
*/
export function BrandTextInput({
leadingIcon,
secureToggle = false,
helperText,
secureTextEntry,
...rest
}: BrandTextInputProps) {
const [hidden, setHidden] = useState(secureTextEntry ?? false);
return (
<View style={styles.wrapper}>
<View style={styles.inputRow}>
{leadingIcon ? (
<SymbolView
name={leadingIcon as Parameters<typeof SymbolView>[0]['name']}
tintColor={Brand.textMuted}
size={18}
style={styles.leadingIcon}
/>
) : null}
<TextInput
{...rest}
secureTextEntry={hidden}
placeholderTextColor={Brand.textFaint}
selectionColor={Brand.accent}
style={styles.input}
/>
{secureToggle ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={hidden ? 'Show password' : 'Hide password'}
hitSlop={8}
onPress={() => setHidden((v) => !v)}
style={styles.trailingIcon}>
<SymbolView
name={
(hidden ? 'eye' : 'eye.slash') as Parameters<
typeof SymbolView
>[0]['name']
}
tintColor={Brand.textMuted}
size={18}
/>
</Pressable>
) : null}
</View>
{helperText ? <></> : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
alignSelf: 'stretch',
},
inputRow: {
height: 55,
borderRadius: 10,
backgroundColor: Brand.inputBg,
borderWidth: 1,
borderColor: Brand.border,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
},
leadingIcon: {
marginRight: 12,
},
trailingIcon: {
marginLeft: 8,
padding: 4,
},
input: {
flex: 1,
color: Brand.text,
fontSize: 15,
lineHeight: 20,
fontFamily: BrandFont.poppinsRegular,
padding: 0,
},
});

View File

@@ -0,0 +1,128 @@
import { SymbolView } from 'expo-symbols';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Brand, BrandFont } from '@/constants/theme';
export type GameTileProps = {
id: string;
name: string;
genre: string;
gradient: readonly [string, string];
/** When true, draws the magenta selection border + checkmark. */
selected?: boolean;
/** Optional image source; falls back to the gradient placeholder. */
imageSource?: string;
onPress?: () => void;
};
/**
* 166×246 game card used on the "Which games do you play?" and "Search games"
* screens. The card is pressable; when `selected`, it gets a 4px magenta
* border and a small white checkmark in the top-right corner (frame 2 in the
* Figma).
*/
export function GameTile({
name,
genre,
gradient,
selected = false,
imageSource,
onPress,
}: GameTileProps) {
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityLabel={`${name} (${genre})`}
onPress={onPress}
style={({ pressed }) => [
styles.card,
selected && styles.cardSelected,
pressed && styles.pressed,
]}>
<View style={styles.artwork}>
{imageSource ? (
<Image source={{ uri: imageSource }} style={StyleSheet.absoluteFill} contentFit="cover" />
) : (
<LinearGradient
colors={[...gradient]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
)}
</View>
<View style={styles.body}>
<Text numberOfLines={1} style={styles.name}>
{name}
</Text>
<Text numberOfLines={1} style={styles.genre}>
{genre}
</Text>
</View>
{selected ? (
<View style={styles.checkmark}>
<SymbolView
name="checkmark"
tintColor={Brand.text}
size={12}
weight="bold"
/>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
width: 166,
height: 246,
backgroundColor: Brand.surface,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 4,
borderColor: 'transparent',
},
cardSelected: {
borderColor: Brand.accent,
},
pressed: {
opacity: 0.85,
},
artwork: {
height: 170,
width: '100%',
},
body: {
flex: 1,
paddingHorizontal: 12,
paddingTop: 10,
gap: 2,
},
name: {
color: Brand.text,
fontSize: 15,
lineHeight: 20,
fontFamily: BrandFont.montserratSemiBold,
},
genre: {
color: Brand.textMuted,
fontSize: 12,
lineHeight: 16,
fontFamily: BrandFont.poppinsRegular,
},
checkmark: {
position: 'absolute',
top: 12,
right: 12,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: Brand.accent,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -35,7 +35,7 @@ export const Fonts = Platform.select({
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
mono: 'ui-monospaced',
},
default: {
sans: 'normal',
@@ -63,3 +63,43 @@ export const Spacing = {
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
export const MaxContentWidth = 800;
/**
* Brand design tokens for the gumble onboarding / auth flow.
* These are dark-only and override the light/dark `Colors` scheme on the screens
* that use the gumble brand (login, onboarding, game selection, search).
*/
export const Brand = {
bg: '#141414',
surface: '#2c2c2c',
surfaceAlt: '#1a1a1a',
border: 'rgba(255,255,255,0.1)',
borderStrong: 'rgba(255,255,255,0.2)',
text: '#ffffff',
textMuted: 'rgba(255,255,255,0.6)',
textFaint: 'rgba(255,255,255,0.4)',
accent: '#e92eba',
accentSoft: 'rgba(233,46,186,0.12)',
accentLight: '#f3c',
inputBg: 'rgba(255,255,255,0.03)',
gradient: ['#f3c', '#e62eb8'] as const,
} as const;
/**
* Font family names loaded by `@expo-google-fonts/montserrat` and
* `@expo-google-fonts/poppins`. Use as `fontFamily: BrandFont.X`.
* These are the same names that the loader exposes as keys in its
* `useFonts({...})` map.
*/
export const BrandFont = {
// Poppins
poppinsRegular: 'Poppins_400Regular',
poppinsMedium: 'Poppins_500Medium',
poppinsSemiBold: 'Poppins_600SemiBold',
poppinsBold: 'Poppins_700Bold',
// Montserrat
montserratRegular: 'Montserrat_400Regular',
montserratMedium: 'Montserrat_500Medium',
montserratSemiBold: 'Montserrat_600SemiBold',
montserratBold: 'Montserrat_700Bold',
} as const;

72
src/data/games.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* In-memory catalogue of games shown on the "Which games do you play?" and
* "Search games" screens. Each entry provides the visual identity used by the
* placeholder tile; the real game artwork can be dropped in later by passing
* a custom `image` to `<GameTile>`.
*/
export type Game = {
id: string;
name: string;
genre: string;
/** Two-stop gradient used as the tile's placeholder artwork. */
gradient: readonly [string, string];
/** Marks a tile as one of the "Most Popular" picks shown in search. */
popular?: boolean;
};
export const GAMES: Game[] = [
{
id: 'mobile-legends',
name: 'Mobile Legends',
genre: 'Strategy',
gradient: ['#1a0033', '#4d0066'],
popular: true,
},
{
id: 'pubg-mobile',
name: 'PUBG MOBILE',
genre: 'Battle Royale',
gradient: ['#332200', '#664400'],
popular: true,
},
{
id: 'free-fire',
name: 'Free Fire',
genre: 'Battle Royale',
gradient: ['#3a1010', '#7a2222'],
popular: true,
},
{
id: 'brawl-stars',
name: 'Brawl Stars',
genre: 'Action',
gradient: ['#1f2a00', '#3f5600'],
popular: true,
},
{
id: 'genshin-impact',
name: 'Genshin Impact',
genre: 'Adventure',
gradient: ['#002a33', '#005566'],
},
{
id: 'honor-of-kings',
name: 'Honor of Kings',
genre: 'MOBA',
gradient: ['#2a0014', '#560028'],
},
{
id: 'clash-royale',
name: 'Clash Royale',
genre: 'Strategy',
gradient: ['#2c1a00', '#583400'],
},
{
id: 'cod-mobile',
name: 'Call of Duty Mobile',
genre: 'Shooter',
gradient: ['#001a2a', '#003355'],
},
];
export const POPULAR_GAMES = GAMES.filter((g) => g.popular);

View File

@@ -0,0 +1,25 @@
import { useMemo } from 'react';
import { GAMES, type Game } from '@/data/games';
/**
* Returns the games that match a free-text query, or the popular list when the
* query is empty. Match is case-insensitive and substring-based on the game
* name. The popular ordering puts the most popular games first; when the user
* has typed a query, results are sorted by name length (shortest first) so the
* closest match surfaces.
*/
export function filterGames(query: string): Game[] {
const trimmed = query.trim();
if (trimmed.length === 0) {
return GAMES.filter((g) => g.popular);
}
const needle = trimmed.toLowerCase();
return GAMES.filter((g) => g.name.toLowerCase().includes(needle)).sort(
(a, b) => a.name.length - b.name.length,
);
}
export function useGameSearch(query: string): Game[] {
return useMemo(() => filterGames(query), [query]);
}