diff --git a/app.json b/app.json index bc5b2a1..f2b5640 100644 --- a/app.json +++ b/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", diff --git a/package-lock.json b/package-lock.json index bba9862..8ad23ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a9c8f27..65b249f 100644 --- a/package.json +++ b/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" }, diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx new file mode 100644 index 0000000..6222cd1 --- /dev/null +++ b/src/app/(auth)/_layout.tsx @@ -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 ( + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: Brand.bg, + }, +}); diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx new file mode 100644 index 0000000..0a2587f --- /dev/null +++ b/src/app/(auth)/login.tsx @@ -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 ( + + + + + + + + + Welcome back! + Log in to your account + + + + setTab('signup')} style={styles.tab}> + + Sign Up + + {tab === 'signup' ? : null} + + setTab('login')} style={styles.tab}> + + Log In + + {tab === 'login' ? : null} + + + + + + + + + + Forgot password? + + + router.push('/(auth)/select-games')} + /> + + + + or continue with + + + + + + + + + + + ); +} + +function HeroSilhouette() { + return ( + + + + + + + ); +} + +function SocialButton({ label }: { label: string }) { + return ( + [styles.socialButton, pressed && styles.socialPressed]}> + {label} + + ); +} + +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', + }, +}); diff --git a/src/app/(auth)/onboarding.tsx b/src/app/(auth)/onboarding.tsx new file mode 100644 index 0000000..e5a6936 --- /dev/null +++ b/src/app/(auth)/onboarding.tsx @@ -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 ( + + + + G + + Gumble + + + + + + + + + + + Gamble your games, gamble your skills + + Play mini-games, complete tasks, and earn rewards — all powered by your favorite games. + + + + + router.push('/(auth)/login')} /> + router.push('/(auth)/login')} + /> + + + ); +} + +function GamePadIllustration() { + return ( + + + + + + + + + + + + + + ); +} + +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)', + }, +}); diff --git a/src/app/(auth)/search-games.tsx b/src/app/(auth)/search-games.tsx new file mode 100644 index 0000000..34588e6 --- /dev/null +++ b/src/app/(auth)/search-games.tsx @@ -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 ( + + + + + + + {sectionTitle} + router.back()} style={styles.cancel}> + Cancel + + + + + {results.length === 0 ? ( + No games match “{query}”. + ) : ( + results.map((game) => ( + { + // Demo: a tap on a result just clears the query so the user + // can try another search. Real apps would add the game. + setQuery(''); + }} + /> + )) + )} + + + ); +} + +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', + }, +}); diff --git a/src/app/(auth)/select-games.tsx b/src/app/(auth)/select-games.tsx new file mode 100644 index 0000000..c635275 --- /dev/null +++ b/src/app/(auth)/select-games.tsx @@ -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` of selected ids. + */ +export default function SelectGamesScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [selected, setSelected] = useState>(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 ( + + + Which games do you play? + + Pick the games you play the most to personalize your experience. + + + + + {GAMES.map((game) => ( + toggle(game.id)} + /> + ))} + + + + 0 ? ` (${selected.size})` : ''}`} + disabled={selected.size === 0} + onPress={() => router.push('/(auth)/search-games')} + /> + router.push('/(auth)/search-games')} + style={styles.link}> + Search for a game + + + + ); +} + +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, + }, +}); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index b58e11f..0cc7532 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -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 ( - + - + + ); } diff --git a/src/app/index.tsx b/src/app/index.tsx index 8ec3e6f..c68e3b1 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -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 use browser devtools; - } - if (Device.isDevice) { - return ( - - shake device or press m in terminal - - ); - } - const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d'; - return ( - - press {shortcut} - - ); + // Nothing to render here while we redirect. + return null; } - -export default function HomeScreen() { - return ( - - - - - - Welcome to Expo - - - - - get started - - - - src/app/index.tsx} - /> - - npm run reset-project} - /> - - - {Platform.OS === 'web' && } - - - ); -} - -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, - }, -}); diff --git a/src/components/brand-pressable.tsx b/src/components/brand-pressable.tsx new file mode 100644 index 0000000..221265f --- /dev/null +++ b/src/components/brand-pressable.tsx @@ -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; +}; + +/** + * 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 ( + [ + styles.base, + styles.outline, + disabled && styles.disabled, + pressed && styles.pressed, + style, + ]}> + {label} + + ); + } + + return ( + [ + styles.base, + disabled && styles.disabled, + pressed && styles.pressed, + style, + ]}> + + {label} + + + ); +} + +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, + }, +}); diff --git a/src/components/brand-text-input.tsx b/src/components/brand-text-input.tsx new file mode 100644 index 0000000..e0a3daa --- /dev/null +++ b/src/components/brand-text-input.tsx @@ -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 & { + /** 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 ( + + + {leadingIcon ? ( + [0]['name']} + tintColor={Brand.textMuted} + size={18} + style={styles.leadingIcon} + /> + ) : null} + + {secureToggle ? ( + setHidden((v) => !v)} + style={styles.trailingIcon}> + [0]['name'] + } + tintColor={Brand.textMuted} + size={18} + /> + + ) : null} + + {helperText ? <> : null} + + ); +} + +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, + }, +}); diff --git a/src/components/game-tile.tsx b/src/components/game-tile.tsx new file mode 100644 index 0000000..49c7023 --- /dev/null +++ b/src/components/game-tile.tsx @@ -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 ( + [ + styles.card, + selected && styles.cardSelected, + pressed && styles.pressed, + ]}> + + {imageSource ? ( + + ) : ( + + )} + + + + {name} + + + {genre} + + + {selected ? ( + + + + ) : null} + + ); +} + +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', + }, +}); diff --git a/src/constants/theme.ts b/src/constants/theme.ts index c10ed27..464926c 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -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; diff --git a/src/data/games.ts b/src/data/games.ts new file mode 100644 index 0000000..ff103b4 --- /dev/null +++ b/src/data/games.ts @@ -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 ``. + */ +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); diff --git a/src/hooks/use-game-search.ts b/src/hooks/use-game-search.ts new file mode 100644 index 0000000..b8b2d37 --- /dev/null +++ b/src/hooks/use-game-search.ts @@ -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]); +}