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]);
+}