first commit
This commit is contained in:
6
app.json
6
app.json
@@ -8,7 +8,8 @@
|
||||
"scheme": "gumble",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"icon": "./assets/expo.icon"
|
||||
"icon": "./assets/expo.icon",
|
||||
"bundleIdentifier": "com.anonymous.gumble"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
@@ -17,7 +18,8 @@
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.anonymous.gumble"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "gumble",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/montserrat": "^0.4.2",
|
||||
"@expo-google-fonts/poppins": "^0.4.1",
|
||||
"@expo/ui": "~56.0.18",
|
||||
"expo": "~56.0.12",
|
||||
"expo-constants": "~56.0.18",
|
||||
@@ -15,6 +17,7 @@
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-glass-effect": "~56.0.4",
|
||||
"expo-image": "~56.0.11",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
@@ -23,13 +26,13 @@
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1154,6 +1157,18 @@
|
||||
"integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==",
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/@expo-google-fonts/montserrat": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/montserrat/-/montserrat-0.4.2.tgz",
|
||||
"integrity": "sha512-Xyq7rKJhhZOE1Xk4wKSSVoUeOkFmQEMhtZ3pdFrgYckGZMs115Apmld1AI8Ju+yrl5GzDdfRCCqRr33dvEDYUA==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo-google-fonts/poppins": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/poppins/-/poppins-0.4.1.tgz",
|
||||
"integrity": "sha512-LiX5oEryJqDt3UmWwr4oqf11hGMz/JEffkkNcZ3UtGwyoMFzXmBmTwcrAWrEttxypxmLNmPykDg+CY2VjKve9A==",
|
||||
"license": "MIT AND OFL-1.1"
|
||||
},
|
||||
"node_modules/@expo/code-signing-certificates": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
||||
@@ -3790,6 +3805,17 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linear-gradient": {
|
||||
"version": "56.0.4",
|
||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-56.0.4.tgz",
|
||||
"integrity": "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linking": {
|
||||
"version": "56.0.14",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.14.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -3,6 +3,8 @@
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/montserrat": "^0.4.2",
|
||||
"@expo-google-fonts/poppins": "^0.4.1",
|
||||
"@expo/ui": "~56.0.18",
|
||||
"expo": "~56.0.12",
|
||||
"expo-constants": "~56.0.18",
|
||||
@@ -10,6 +12,7 @@
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-glass-effect": "~56.0.4",
|
||||
"expo-image": "~56.0.11",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
@@ -18,13 +21,13 @@
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,8 +37,8 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
|
||||
32
src/app/(auth)/_layout.tsx
Normal file
32
src/app/(auth)/_layout.tsx
Normal 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
283
src/app/(auth)/login.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
206
src/app/(auth)/onboarding.tsx
Normal file
206
src/app/(auth)/onboarding.tsx
Normal 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)',
|
||||
},
|
||||
});
|
||||
117
src/app/(auth)/search-games.tsx
Normal file
117
src/app/(auth)/search-games.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
116
src/app/(auth)/select-games.tsx
Normal file
116
src/app/(auth)/select-games.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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,
|
||||
},
|
||||
});
|
||||
|
||||
108
src/components/brand-pressable.tsx
Normal file
108
src/components/brand-pressable.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
107
src/components/brand-text-input.tsx
Normal file
107
src/components/brand-text-input.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
128
src/components/game-tile.tsx
Normal file
128
src/components/game-tile.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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
72
src/data/games.ts
Normal 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);
|
||||
25
src/hooks/use-game-search.ts
Normal file
25
src/hooks/use-game-search.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user