diff --git a/__fixtures__/test-project/.gitignore b/__fixtures__/test-project/.gitignore
index 31d9637ede..a43a187263 100644
--- a/__fixtures__/test-project/.gitignore
+++ b/__fixtures__/test-project/.gitignore
@@ -22,3 +22,18 @@ api/src/lib/generateGraphiQLHeader.*
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+
+# Expo
+.expo
+web-build
+expo-env.d.ts
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+.metro-health-check*
+*.pem
+*.tsbuildinfo
diff --git a/__fixtures__/test-project/app/.env.example b/__fixtures__/test-project/app/.env.example
new file mode 100644
index 0000000000..4f700f16d3
--- /dev/null
+++ b/__fixtures__/test-project/app/.env.example
@@ -0,0 +1,2 @@
+# EXPO_PUBLIC_RWJS_API_GRAPHQL_URL=http://localhost:8911/graphql
+# EXPO_PUBLIC_API_URL=http://localhost:8911
diff --git a/__fixtures__/test-project/app/ambient.d.ts b/__fixtures__/test-project/app/ambient.d.ts
new file mode 100644
index 0000000000..6e4456a167
--- /dev/null
+++ b/__fixtures__/test-project/app/ambient.d.ts
@@ -0,0 +1,33 @@
+/* eslint-disable no-var */
+
+declare global {
+ /**
+ * FQDN or absolute path to the GraphQL serverless function, without the trailing slash.
+ * Example: `./redwood/functions/graphql` or `https://api.redwoodjs.com/graphql`
+ */
+ var RWJS_API_GRAPHQL_URL: string
+
+ /**
+ * URL or absolute path to serverless functions, without the trailing slash.
+ * Example: `./redwood/functions/` or `https://api.redwoodjs.com/`
+ **/
+ var RWJS_API_URL: string
+
+ // Provided by Vite.config in the user's project
+ var RWJS_ENV: {
+ RWJS_API_GRAPHQL_URL: string
+ /** URL or absolute path to serverless functions */
+ RWJS_API_URL: string
+ }
+
+ namespace NodeJS {
+ interface Global {
+ /** URL or absolute path to the GraphQL serverless function */
+ RWJS_API_GRAPHQL_URL: string
+ /** URL or absolute path to serverless functions */
+ RWJS_API_URL: string
+ }
+ }
+}
+
+export {}
diff --git a/__fixtures__/test-project/app/app.json b/__fixtures__/test-project/app/app.json
new file mode 100644
index 0000000000..428a0b339a
--- /dev/null
+++ b/__fixtures__/test-project/app/app.json
@@ -0,0 +1,42 @@
+{
+ "expo": {
+ "name": "app",
+ "slug": "app",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "app",
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/images/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "edgeToEdgeEnabled": true
+ },
+ "web": {
+ "bundler": "metro",
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/images/splash-icon.png",
+ "imageWidth": 200,
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ }
+ ]
+ ],
+ "experiments": {
+ "typedRoutes": true
+ }
+ }
+}
diff --git a/__fixtures__/test-project/app/app/(tabs)/_layout.tsx b/__fixtures__/test-project/app/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000000..dde407a022
--- /dev/null
+++ b/__fixtures__/test-project/app/app/(tabs)/_layout.tsx
@@ -0,0 +1,65 @@
+import React from 'react'
+
+import MaterialIcons from '@expo/vector-icons/MaterialIcons'
+import { Link, Tabs } from 'expo-router'
+import { Button, Platform, View } from 'react-native'
+
+import { HapticTab } from '@/components/HapticTab'
+import TabBarBackground from '@/components/ui/TabBarBackground'
+import { Colors } from '@/constants/Colors'
+import { useAuth } from '@/context/auth'
+import { useColorScheme } from '@/hooks/useColorScheme'
+
+export default function TabLayout() {
+ const colorScheme = useColorScheme()
+ const { isAuthenticated, logOut } = useAuth()
+
+ return (
+ (
+
+ {isAuthenticated ? (
+
+ ) : (
+ Login
+ )}
+
+ ),
+ }}
+ >
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+
+ )
+}
diff --git a/__fixtures__/test-project/app/app/(tabs)/admin.tsx b/__fixtures__/test-project/app/app/(tabs)/admin.tsx
new file mode 100644
index 0000000000..adac650cf4
--- /dev/null
+++ b/__fixtures__/test-project/app/app/(tabs)/admin.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+
+import { gql, useQuery } from '@apollo/client'
+
+import { Post, PostProps } from '@/components/Post'
+import ThemedScrollView from '@/components/ThemedScrollView'
+
+export const FIND_POSTS_QUERY = gql`
+ query FindPosts {
+ posts {
+ id
+ title
+ body
+ author {
+ email
+ fullName
+ }
+ createdAt
+ }
+ }
+`
+
+export default function AdminScreen() {
+ const { data } = useQuery<{ posts: PostProps['post'][] }>(FIND_POSTS_QUERY)
+
+ const posts = data?.posts ?? []
+
+ return (
+
+ {posts.map((post) => (
+
+ ))}
+
+ )
+}
diff --git a/__fixtures__/test-project/app/app/(tabs)/index.tsx b/__fixtures__/test-project/app/app/(tabs)/index.tsx
new file mode 100644
index 0000000000..94f15ec840
--- /dev/null
+++ b/__fixtures__/test-project/app/app/(tabs)/index.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+
+import { gql, useQuery } from '@apollo/client'
+
+import { BlogPost, BlogPostProps } from '@/components/BlogPost'
+import ThemedScrollView from '@/components/ThemedScrollView'
+import { ThemedView } from '@/components/ThemedView'
+
+const QUERY = gql`
+ query BlogPostsQuery {
+ blogPosts: posts {
+ id
+ title
+ body
+ author {
+ email
+ fullName
+ }
+ createdAt
+ }
+ }
+`
+
+export default function HomeScreen() {
+ const { data } = useQuery(QUERY)
+
+ const blogPosts: BlogPostProps['blogPost'][] = data?.blogPosts ?? []
+
+ return (
+
+
+ {blogPosts.map((blogPost, index) => (
+
+ ))}
+
+
+ )
+}
diff --git a/__fixtures__/test-project/app/app/+not-found.tsx b/__fixtures__/test-project/app/app/+not-found.tsx
new file mode 100644
index 0000000000..062898069d
--- /dev/null
+++ b/__fixtures__/test-project/app/app/+not-found.tsx
@@ -0,0 +1,34 @@
+import React from 'react'
+
+import { Link, Stack } from 'expo-router'
+import { StyleSheet } from 'react-native'
+
+import { ThemedText } from '@/components/ThemedText'
+import { ThemedView } from '@/components/ThemedView'
+
+export default function NotFoundScreen() {
+ return (
+ <>
+
+
+ This screen does not exist.
+
+ Go to home screen!
+
+
+ >
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ link: {
+ marginTop: 15,
+ paddingVertical: 15,
+ },
+})
diff --git a/__fixtures__/test-project/app/app/_layout.tsx b/__fixtures__/test-project/app/app/_layout.tsx
new file mode 100644
index 0000000000..96ae354cce
--- /dev/null
+++ b/__fixtures__/test-project/app/app/_layout.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+
+import {
+ DarkTheme,
+ DefaultTheme,
+ ThemeProvider,
+} from '@react-navigation/native'
+import { useFonts } from 'expo-font'
+import { Stack } from 'expo-router'
+import { StatusBar } from 'expo-status-bar'
+
+import { RedwoodApolloProvider } from '@cedarjs/web/apollo'
+
+import { AuthProvider, useAuth } from '@/context/auth'
+import { useColorScheme } from '@/hooks/useColorScheme'
+
+import 'react-native-reanimated'
+
+export default function RootLayout() {
+ const colorScheme = useColorScheme()
+ const [loaded] = useFonts({
+ SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
+ })
+
+ if (!loaded) {
+ // Async font loading only occurs in development.
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/__fixtures__/test-project/app/app/modal.tsx b/__fixtures__/test-project/app/app/modal.tsx
new file mode 100644
index 0000000000..e568354265
--- /dev/null
+++ b/__fixtures__/test-project/app/app/modal.tsx
@@ -0,0 +1,77 @@
+import React, { useEffect, useState } from 'react'
+
+import { useRouter } from 'expo-router'
+import { Alert, Button, StyleSheet, TextInput } from 'react-native'
+
+import { ThemedView } from '@/components/ThemedView'
+import { useAuth } from '@/context/auth'
+
+export default function Modal() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const { isAuthenticated, logIn } = useAuth()
+ const router = useRouter()
+
+ useEffect(() => {
+ if (isAuthenticated && router.canGoBack()) {
+ router.push('../')
+ }
+ }, [isAuthenticated])
+
+ const onLogin = async () => {
+ if (!username || !password) {
+ Alert.alert('Alert', `Both username and password are required`)
+ return
+ }
+
+ const response = await logIn({ username, password })
+
+ if (response.message) {
+ Alert.alert('Alert', response.message)
+ } else if (response.error) {
+ Alert.alert('Alert', response.error)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ padding: 20,
+ },
+ title: {
+ fontSize: 28,
+ marginBottom: 20,
+ textAlign: 'center',
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ccc',
+ padding: 12,
+ marginBottom: 16,
+ borderRadius: 6,
+ },
+})
diff --git a/__fixtures__/test-project/app/app/posts/[id].tsx b/__fixtures__/test-project/app/app/posts/[id].tsx
new file mode 100644
index 0000000000..a818e93bb9
--- /dev/null
+++ b/__fixtures__/test-project/app/app/posts/[id].tsx
@@ -0,0 +1,47 @@
+import React, { useEffect } from 'react'
+
+import { gql, useQuery } from '@apollo/client'
+import { useLocalSearchParams, useNavigation } from 'expo-router'
+
+import { BlogPost, BlogPostProps } from '@/components/BlogPost'
+import ThemedScrollView from '@/components/ThemedScrollView'
+import { ThemedView } from '@/components/ThemedView'
+
+const QUERY = gql`
+ query FindPostById($id: Int!) {
+ post(id: $id) {
+ id
+ title
+ body
+ author {
+ email
+ fullName
+ }
+ createdAt
+ }
+ }
+`
+
+export default function PostScreen() {
+ const navigation = useNavigation()
+ const { id, title } = useLocalSearchParams<{ id: string; title: string }>()
+ const { data } = useQuery<{ post: BlogPostProps['blogPost'] }>(QUERY, {
+ variables: { id: Number(id) },
+ })
+
+ useEffect(() => {
+ navigation.setOptions({ title })
+ }, [])
+
+ const blogPost = data?.post
+
+ return (
+
+
+ {blogPost ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/__fixtures__/test-project/app/assets/fonts/SpaceMono-Regular.ttf b/__fixtures__/test-project/app/assets/fonts/SpaceMono-Regular.ttf
new file mode 100755
index 0000000000..28d7ff7177
Binary files /dev/null and b/__fixtures__/test-project/app/assets/fonts/SpaceMono-Regular.ttf differ
diff --git a/__fixtures__/test-project/app/assets/images/adaptive-icon.png b/__fixtures__/test-project/app/assets/images/adaptive-icon.png
new file mode 100644
index 0000000000..03d6f6b6c6
Binary files /dev/null and b/__fixtures__/test-project/app/assets/images/adaptive-icon.png differ
diff --git a/__fixtures__/test-project/app/assets/images/favicon.png b/__fixtures__/test-project/app/assets/images/favicon.png
new file mode 100644
index 0000000000..e75f697b18
Binary files /dev/null and b/__fixtures__/test-project/app/assets/images/favicon.png differ
diff --git a/__fixtures__/test-project/app/assets/images/icon.png b/__fixtures__/test-project/app/assets/images/icon.png
new file mode 100644
index 0000000000..a0b1526fc7
Binary files /dev/null and b/__fixtures__/test-project/app/assets/images/icon.png differ
diff --git a/__fixtures__/test-project/app/assets/images/splash-icon.png b/__fixtures__/test-project/app/assets/images/splash-icon.png
new file mode 100644
index 0000000000..03d6f6b6c6
Binary files /dev/null and b/__fixtures__/test-project/app/assets/images/splash-icon.png differ
diff --git a/__fixtures__/test-project/app/components/BlogPost.tsx b/__fixtures__/test-project/app/components/BlogPost.tsx
new file mode 100644
index 0000000000..9044368647
--- /dev/null
+++ b/__fixtures__/test-project/app/components/BlogPost.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+
+import { Link } from 'expo-router'
+import { StyleSheet } from 'react-native'
+
+import { ThemedText } from './ThemedText'
+import { ThemedView } from './ThemedView'
+
+export interface BlogPostProps {
+ blogPost: {
+ id: number
+ title: string
+ body: string
+ createdAt: string
+ author: { email: string; fullName: string }
+ }
+ isLast: boolean
+}
+
+export function BlogPost({ blogPost, isLast }: BlogPostProps) {
+ return (
+
+
+ {new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(new Date(blogPost.createdAt))}{' '}
+ - By: {blogPost.author.fullName}
+
+ ({blogPost.author.email})
+
+ {blogPost.title}
+
+
+ {blogPost.body}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ info: {
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ body: {
+ marginBlock: 8,
+ },
+ border: {
+ paddingBottom: 8,
+ borderBottomColor: '#e5e7eb',
+ borderBottomWidth: 1,
+ },
+})
diff --git a/__fixtures__/test-project/app/components/HapticTab.tsx b/__fixtures__/test-project/app/components/HapticTab.tsx
new file mode 100644
index 0000000000..3cac35f421
--- /dev/null
+++ b/__fixtures__/test-project/app/components/HapticTab.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+
+import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'
+import { PlatformPressable } from '@react-navigation/elements'
+import * as Haptics from 'expo-haptics'
+
+export function HapticTab(props: BottomTabBarButtonProps) {
+ return (
+ {
+ if (process.env.EXPO_OS === 'ios') {
+ // Add a soft haptic feedback when pressing down on the tabs.
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
+ }
+ props.onPressIn?.(ev)
+ }}
+ />
+ )
+}
diff --git a/__fixtures__/test-project/app/components/Post.tsx b/__fixtures__/test-project/app/components/Post.tsx
new file mode 100644
index 0000000000..6d04b20282
--- /dev/null
+++ b/__fixtures__/test-project/app/components/Post.tsx
@@ -0,0 +1,106 @@
+import React from 'react'
+
+import { gql, useMutation } from '@apollo/client'
+import { Link } from 'expo-router'
+import { Alert, Button, StyleSheet } from 'react-native'
+
+import { ThemedText } from './ThemedText'
+import { ThemedView } from './ThemedView'
+
+import { FIND_POSTS_QUERY } from '@/app/(tabs)/admin'
+
+const DELETE_POST_MUTATION = gql`
+ mutation DeletePostMutation($id: Int!) {
+ deletePost(id: $id) {
+ id
+ }
+ }
+`
+
+export interface PostProps {
+ post: {
+ id: number
+ title: string
+ body: string
+ createdAt: string
+ author: { email: string; fullName: string }
+ }
+}
+
+export function Post({ post }: PostProps) {
+ const [deletePost] = useMutation(DELETE_POST_MUTATION, {
+ onCompleted: () => {
+ Alert.alert('Alert', 'Post deleted')
+ },
+ onError: (error) => {
+ Alert.alert('Alert', error.message)
+ },
+ // This refetches the query on the list page. Read more about other ways to
+ // update the cache over here:
+ // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
+ refetchQueries: [{ query: FIND_POSTS_QUERY }],
+ awaitRefetchQueries: true,
+ })
+
+ const onDelete = () => {
+ Alert.alert('Alert', `Are you sure you want to delete post ${post.id}?`, [
+ { text: 'Cancel' },
+ { text: 'OK', onPress: () => deletePost({ variables: { id: post.id } }) },
+ ])
+ }
+
+ return (
+
+
+ {new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(new Date(post.createdAt))}{' '}
+ - By: {post.author.fullName}
+
+
+ {post.id}: {post.title}
+
+ ID: {post.id}
+
+
+
+ Show
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 8,
+ borderColor: '#e5e7eb',
+ borderWidth: 1,
+ },
+ info: {
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ show: {
+ fontSize: 12,
+ lineHeight: 16,
+ textTransform: 'uppercase',
+ color: '#6b7280',
+ },
+ actions: {
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 16,
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ },
+})
diff --git a/__fixtures__/test-project/app/components/ThemedScrollView.tsx b/__fixtures__/test-project/app/components/ThemedScrollView.tsx
new file mode 100644
index 0000000000..20c0441269
--- /dev/null
+++ b/__fixtures__/test-project/app/components/ThemedScrollView.tsx
@@ -0,0 +1,38 @@
+import type { PropsWithChildren } from 'react'
+import React from 'react'
+
+import { ScrollView, StyleSheet } from 'react-native'
+import Animated, { useAnimatedRef } from 'react-native-reanimated'
+
+import { ThemedView } from '@/components/ThemedView'
+import { useBottomTabOverflow } from '@/components/ui/TabBarBackground'
+
+export default function ThemedScrollView({ children }: PropsWithChildren) {
+ const scrollRef = useAnimatedRef()
+ const bottom = useBottomTabOverflow()
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ padding: 32,
+ gap: 16,
+ overflow: 'hidden',
+ },
+})
diff --git a/__fixtures__/test-project/app/components/ThemedText.tsx b/__fixtures__/test-project/app/components/ThemedText.tsx
new file mode 100644
index 0000000000..317b375493
--- /dev/null
+++ b/__fixtures__/test-project/app/components/ThemedText.tsx
@@ -0,0 +1,62 @@
+import React from 'react'
+
+import { StyleSheet, Text, type TextProps } from 'react-native'
+
+import { useThemeColor } from '@/hooks/useThemeColor'
+
+export type ThemedTextProps = TextProps & {
+ lightColor?: string
+ darkColor?: string
+ type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'
+}
+
+export function ThemedText({
+ style,
+ lightColor,
+ darkColor,
+ type = 'default',
+ ...rest
+}: ThemedTextProps) {
+ const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text')
+
+ return (
+
+ )
+}
+
+const styles = StyleSheet.create({
+ default: {
+ fontSize: 16,
+ lineHeight: 24,
+ },
+ defaultSemiBold: {
+ fontSize: 16,
+ lineHeight: 24,
+ fontWeight: '600',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ lineHeight: 32,
+ },
+ subtitle: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ link: {
+ lineHeight: 30,
+ fontSize: 16,
+ color: '#0a7ea4',
+ },
+})
diff --git a/__fixtures__/test-project/app/components/ThemedView.tsx b/__fixtures__/test-project/app/components/ThemedView.tsx
new file mode 100644
index 0000000000..0e24ab3588
--- /dev/null
+++ b/__fixtures__/test-project/app/components/ThemedView.tsx
@@ -0,0 +1,24 @@
+import React from 'react'
+
+import { View, type ViewProps } from 'react-native'
+
+import { useThemeColor } from '@/hooks/useThemeColor'
+
+export type ThemedViewProps = ViewProps & {
+ lightColor?: string
+ darkColor?: string
+}
+
+export function ThemedView({
+ style,
+ lightColor,
+ darkColor,
+ ...otherProps
+}: ThemedViewProps) {
+ const backgroundColor = useThemeColor(
+ { light: lightColor, dark: darkColor },
+ 'background'
+ )
+
+ return
+}
diff --git a/__fixtures__/test-project/app/components/ui/TabBarBackground.ios.tsx b/__fixtures__/test-project/app/components/ui/TabBarBackground.ios.tsx
new file mode 100644
index 0000000000..e1374703de
--- /dev/null
+++ b/__fixtures__/test-project/app/components/ui/TabBarBackground.ios.tsx
@@ -0,0 +1,21 @@
+import React from 'react'
+
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'
+import { BlurView } from 'expo-blur'
+import { StyleSheet } from 'react-native'
+
+export default function BlurTabBarBackground() {
+ return (
+
+ )
+}
+
+export function useBottomTabOverflow() {
+ return useBottomTabBarHeight()
+}
diff --git a/__fixtures__/test-project/app/components/ui/TabBarBackground.tsx b/__fixtures__/test-project/app/components/ui/TabBarBackground.tsx
new file mode 100644
index 0000000000..5373d49025
--- /dev/null
+++ b/__fixtures__/test-project/app/components/ui/TabBarBackground.tsx
@@ -0,0 +1,6 @@
+// This is a shim for web and Android where the tab bar is generally opaque.
+export default undefined
+
+export function useBottomTabOverflow() {
+ return 0
+}
diff --git a/__fixtures__/test-project/app/constants/Colors.ts b/__fixtures__/test-project/app/constants/Colors.ts
new file mode 100644
index 0000000000..5889ed1ab8
--- /dev/null
+++ b/__fixtures__/test-project/app/constants/Colors.ts
@@ -0,0 +1,26 @@
+/**
+ * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
+ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
+ */
+
+const tintColorLight = '#0a7ea4'
+const tintColorDark = '#fff'
+
+export const Colors = {
+ light: {
+ text: '#11181C',
+ background: '#fff',
+ tint: tintColorLight,
+ icon: '#687076',
+ tabIconDefault: '#687076',
+ tabIconSelected: tintColorLight,
+ },
+ dark: {
+ text: '#ECEDEE',
+ background: '#151718',
+ tint: tintColorDark,
+ icon: '#9BA1A6',
+ tabIconDefault: '#9BA1A6',
+ tabIconSelected: tintColorDark,
+ },
+}
diff --git a/__fixtures__/test-project/app/context/auth.ts b/__fixtures__/test-project/app/context/auth.ts
new file mode 100644
index 0000000000..ea216f87ab
--- /dev/null
+++ b/__fixtures__/test-project/app/context/auth.ts
@@ -0,0 +1,12 @@
+globalThis.RWJS_ENV = {
+ RWJS_API_GRAPHQL_URL: process.env.EXPO_PUBLIC_RWJS_API_GRAPHQL_URL!,
+ RWJS_API_URL: process.env.EXPO_PUBLIC_API_URL!,
+}
+globalThis.RWJS_API_GRAPHQL_URL = process.env.EXPO_PUBLIC_RWJS_API_GRAPHQL_URL!
+globalThis.RWJS_API_URL = process.env.EXPO_PUBLIC_API_URL!
+
+import { createDbAuthClient, createAuth } from '@cedarjs/auth-dbauth-web'
+
+const dbAuthClient = createDbAuthClient()
+
+export const { AuthProvider, useAuth } = createAuth(dbAuthClient)
diff --git a/__fixtures__/test-project/app/hooks/useColorScheme.ts b/__fixtures__/test-project/app/hooks/useColorScheme.ts
new file mode 100644
index 0000000000..b370337ae8
--- /dev/null
+++ b/__fixtures__/test-project/app/hooks/useColorScheme.ts
@@ -0,0 +1 @@
+export { useColorScheme } from 'react-native'
diff --git a/__fixtures__/test-project/app/hooks/useColorScheme.web.ts b/__fixtures__/test-project/app/hooks/useColorScheme.web.ts
new file mode 100644
index 0000000000..8f3a67513b
--- /dev/null
+++ b/__fixtures__/test-project/app/hooks/useColorScheme.web.ts
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react'
+
+import { useColorScheme as useRNColorScheme } from 'react-native'
+
+/**
+ * To support static rendering, this value needs to be re-calculated on the client side for web
+ */
+export function useColorScheme() {
+ const [hasHydrated, setHasHydrated] = useState(false)
+
+ useEffect(() => {
+ setHasHydrated(true)
+ }, [])
+
+ const colorScheme = useRNColorScheme()
+
+ if (hasHydrated) {
+ return colorScheme
+ }
+
+ return 'light'
+}
diff --git a/__fixtures__/test-project/app/hooks/useThemeColor.ts b/__fixtures__/test-project/app/hooks/useThemeColor.ts
new file mode 100644
index 0000000000..c95428617f
--- /dev/null
+++ b/__fixtures__/test-project/app/hooks/useThemeColor.ts
@@ -0,0 +1,21 @@
+/**
+ * Learn more about light and dark modes:
+ * https://docs.expo.dev/guides/color-schemes/
+ */
+
+import { Colors } from '@/constants/Colors'
+import { useColorScheme } from '@/hooks/useColorScheme'
+
+export function useThemeColor(
+ props: { light?: string; dark?: string },
+ colorName: keyof typeof Colors.light & keyof typeof Colors.dark
+) {
+ const theme = useColorScheme() ?? 'light'
+ const colorFromProps = props[theme]
+
+ if (colorFromProps) {
+ return colorFromProps
+ } else {
+ return Colors[theme][colorName]
+ }
+}
diff --git a/__fixtures__/test-project/app/package.json b/__fixtures__/test-project/app/package.json
new file mode 100644
index 0000000000..2b068af214
--- /dev/null
+++ b/__fixtures__/test-project/app/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "app",
+ "main": "expo-router/entry",
+ "version": "1.0.0",
+ "scripts": {
+ "start": "expo start",
+ "reset-project": "node ./scripts/reset-project.js",
+ "android": "expo start --android",
+ "ios": "expo start --ios",
+ "web": "expo start --web"
+ },
+ "dependencies": {
+ "@cedarjs/auth-dbauth-web": "0.0.5",
+ "@cedarjs/web": "0.0.5",
+ "@expo/vector-icons": "^14.1.0",
+ "@react-navigation/bottom-tabs": "^7.3.10",
+ "@react-navigation/elements": "^2.3.8",
+ "@react-navigation/native": "^7.1.6",
+ "expo": "~53.0.13",
+ "expo-blur": "~14.1.5",
+ "expo-constants": "~17.1.6",
+ "expo-font": "~13.3.1",
+ "expo-haptics": "~14.1.4",
+ "expo-image": "~2.3.0",
+ "expo-linking": "~7.1.5",
+ "expo-router": "~5.1.1",
+ "expo-splash-screen": "~0.30.9",
+ "expo-status-bar": "~2.2.3",
+ "expo-system-ui": "~5.0.9",
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "react-native": "0.79.4",
+ "react-native-gesture-handler": "~2.24.0",
+ "react-native-reanimated": "~3.17.4",
+ "react-native-safe-area-context": "5.4.0",
+ "react-native-screens": "~4.11.1",
+ "react-native-web": "~0.20.0",
+ "react-native-webview": "13.13.5"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.25.2",
+ "@types/react": "~19.0.10"
+ },
+ "private": true
+}
diff --git a/__fixtures__/test-project/app/scripts/reset-project.js b/__fixtures__/test-project/app/scripts/reset-project.js
new file mode 100755
index 0000000000..6f6763a21e
--- /dev/null
+++ b/__fixtures__/test-project/app/scripts/reset-project.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+
+/**
+ * This script is used to reset the project to a blank state.
+ * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
+ * You can remove the `reset-project` script from package.json and safely delete this file after running it.
+ */
+
+const fs = require('fs')
+const path = require('path')
+const readline = require('readline')
+
+const root = process.cwd()
+const oldDirs = ['app', 'components', 'hooks', 'constants', 'scripts']
+const exampleDir = 'app-example'
+const newAppDir = 'app'
+const exampleDirPath = path.join(root, exampleDir)
+
+const indexContent = `import { Text, View } from "react-native";
+
+export default function Index() {
+ return (
+
+ Edit app/index.tsx to edit this screen.
+
+ );
+}
+`
+
+const layoutContent = `import { Stack } from "expo-router";
+
+export default function RootLayout() {
+ return ;
+}
+`
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+})
+
+const moveDirectories = async (userInput) => {
+ try {
+ if (userInput === 'y') {
+ // Create the app-example directory
+ await fs.promises.mkdir(exampleDirPath, { recursive: true })
+ console.log(`š /${exampleDir} directory created.`)
+ }
+
+ // Move old directories to new app-example directory or delete them
+ for (const dir of oldDirs) {
+ const oldDirPath = path.join(root, dir)
+ if (fs.existsSync(oldDirPath)) {
+ if (userInput === 'y') {
+ const newDirPath = path.join(root, exampleDir, dir)
+ await fs.promises.rename(oldDirPath, newDirPath)
+ console.log(`ā”ļø /${dir} moved to /${exampleDir}/${dir}.`)
+ } else {
+ await fs.promises.rm(oldDirPath, { recursive: true, force: true })
+ console.log(`ā /${dir} deleted.`)
+ }
+ } else {
+ console.log(`ā”ļø /${dir} does not exist, skipping.`)
+ }
+ }
+
+ // Create new /app directory
+ const newAppDirPath = path.join(root, newAppDir)
+ await fs.promises.mkdir(newAppDirPath, { recursive: true })
+ console.log('\nš New /app directory created.')
+
+ // Create index.tsx
+ const indexPath = path.join(newAppDirPath, 'index.tsx')
+ await fs.promises.writeFile(indexPath, indexContent)
+ console.log('š app/index.tsx created.')
+
+ // Create _layout.tsx
+ const layoutPath = path.join(newAppDirPath, '_layout.tsx')
+ await fs.promises.writeFile(layoutPath, layoutContent)
+ console.log('š app/_layout.tsx created.')
+
+ console.log('\nā
Project reset complete. Next steps:')
+ console.log(
+ `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
+ userInput === 'y'
+ ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
+ : ''
+ }`
+ )
+ } catch (error) {
+ console.error(`ā Error during script execution: ${error.message}`)
+ }
+}
+
+rl.question(
+ 'Do you want to move existing files to /app-example instead of deleting them? (Y/n): ',
+ (answer) => {
+ const userInput = answer.trim().toLowerCase() || 'y'
+ if (userInput === 'y' || userInput === 'n') {
+ moveDirectories(userInput).finally(() => rl.close())
+ } else {
+ console.log("ā Invalid input. Please enter 'Y' or 'N'.")
+ rl.close()
+ }
+ }
+)
diff --git a/__fixtures__/test-project/app/tsconfig.json b/__fixtures__/test-project/app/tsconfig.json
new file mode 100644
index 0000000000..ce27fee398
--- /dev/null
+++ b/__fixtures__/test-project/app/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+}
diff --git a/__fixtures__/test-project/package.json b/__fixtures__/test-project/package.json
index dc5914a840..c56223cf05 100644
--- a/__fixtures__/test-project/package.json
+++ b/__fixtures__/test-project/package.json
@@ -3,7 +3,8 @@
"workspaces": {
"packages": [
"api",
- "web"
+ "web",
+ "app"
]
},
"devDependencies": {
@@ -25,6 +26,8 @@
"packageManager": "yarn@4.6.0",
"resolutions": {
"@storybook/react-dom-shim@npm:7.6.20": "https://verdaccio.tobbe.dev/@storybook/react-dom-shim/-/react-dom-shim-8.0.8.tgz",
- "react-is": "19.0.0-rc-f2df5694-20240916"
+ "react-is": "19.0.0",
+ "react": "19.0.0",
+ "react-dom": "19.0.0"
}
-}
\ No newline at end of file
+}