├── components ├── AssetForm │ ├── index.tsx │ ├── __tests__ │ │ └── AssetForm.test.tsx │ └── AssetForm.tsx ├── EmptyState │ ├── index.tsx │ ├── EmptyState.tsx │ └── __tests__ │ │ └── EmptyState.test.tsx ├── useColorScheme.ts ├── ErrorBoundary │ ├── index.tsx │ ├── __tests__ │ │ └── ErrorBoundary.test.tsx │ └── ErrorBoundary.tsx ├── LoadingState │ ├── index.tsx │ ├── __tests__ │ │ └── LoadingState.test.tsx │ └── LoadingState.tsx ├── useClientOnlyValue.ts ├── useClientOnlyValue.web.ts ├── useColorScheme.web.ts └── Themed.tsx ├── .vscode ├── extensions.json └── settings.json ├── .firebaserc ├── docs └── app-tour.gif ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── splash-icon.png │ └── adaptive-icon.png └── fonts │ └── SpaceMono-Regular.ttf ├── app ├── (tabs) │ ├── index.tsx │ ├── (my-assets) │ │ ├── _layout.tsx │ │ ├── index.tsx │ │ ├── edit │ │ │ └── [id].tsx │ │ └── details │ │ │ └── [id].tsx │ ├── _layout.tsx │ └── settings.tsx ├── (auth) │ ├── _layout.tsx │ └── login.tsx ├── +not-found.tsx ├── +html.tsx └── _layout.tsx ├── babel.config.js ├── types └── Asset.ts ├── tsconfig.json ├── constants └── Colors.ts ├── jest.config.cjs ├── .gitignore ├── firebase ├── google-services.json └── GoogleService-Info.plist ├── eas.json ├── README.md ├── eslint.config.js ├── app.json ├── context ├── AuthContext.tsx └── FirestoreContext.tsx ├── package.json └── jest-setup.js /components/AssetForm/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './AssetForm'; 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { "recommendations": ["expo.vscode-expo-tools"] } 2 | -------------------------------------------------------------------------------- /components/EmptyState/index.tsx: -------------------------------------------------------------------------------- 1 | export { EmptyState } from './EmptyState'; 2 | -------------------------------------------------------------------------------- /components/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "asset-demo-app" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | export { ErrorBoundary } from './ErrorBoundary'; 2 | -------------------------------------------------------------------------------- /components/LoadingState/index.tsx: -------------------------------------------------------------------------------- 1 | export { LoadingState } from './LoadingState'; 2 | -------------------------------------------------------------------------------- /docs/app-tour.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SKempin/assets-demo-app/main/docs/app-tour.gif -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SKempin/assets-demo-app/main/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SKempin/assets-demo-app/main/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SKempin/assets-demo-app/main/assets/images/splash-icon.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SKempin/assets-demo-app/main/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SKempin/assets-demo-app/main/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /app/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import AssetForm from '@/components/AssetForm'; 2 | 3 | export default function CaptureScreen() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: [], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit", 4 | "source.organizeImports": "explicit", 5 | "source.sortMembers": "explicit" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /components/useClientOnlyValue.ts: -------------------------------------------------------------------------------- 1 | // This function is web-only as native doesn't currently support server (or build-time) rendering. 2 | export function useClientOnlyValue(server: S, client: C): S | C { 3 | return client; 4 | } 5 | -------------------------------------------------------------------------------- /app/(auth)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from 'expo-router'; 2 | 3 | export default function AuthLayout() { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /types/Asset.ts: -------------------------------------------------------------------------------- 1 | import { Timestamp } from 'firebase/firestore'; 2 | 3 | export interface Asset { 4 | id: string; 5 | name: string; 6 | description: string; 7 | location?: string; 8 | attachments?: string[]; 9 | createdAt?: Timestamp; 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /components/useClientOnlyValue.web.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // `useEffect` is not invoked during server rendering, meaning 4 | // we can use this to determine if we're on the server or not. 5 | export function useClientOnlyValue(server: S, client: C): S | C { 6 | const [value, setValue] = React.useState(server); 7 | React.useEffect(() => { 8 | setValue(client); 9 | }, [client]); 10 | 11 | return value; 12 | } 13 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | const tintColorLight = '#2f95dc'; 2 | const tintColorDark = '#fff'; 3 | 4 | export default { 5 | light: { 6 | text: '#000', 7 | background: '#fff', 8 | tint: tintColorLight, 9 | tabIconDefault: '#ccc', 10 | tabIconSelected: tintColorLight, 11 | }, 12 | dark: { 13 | text: '#fff', 14 | background: '#000', 15 | tint: tintColorDark, 16 | tabIconDefault: '#ccc', 17 | tabIconSelected: tintColorDark, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /components/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | // NOTE: The default React Native styling doesn't support server rendering. 2 | // Server rendered styles should not change between the first render of the HTML 3 | // and the first render on the client. Typically, web developers will use CSS media queries 4 | // to render different styles on the client and server, these aren't directly supported in React Native 5 | // but can be achieved using a styling library like Nativewind. 6 | export function useColorScheme() { 7 | return 'light'; 8 | } 9 | -------------------------------------------------------------------------------- /app/(tabs)/(my-assets)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from 'expo-router'; 2 | 3 | export default function IndexLayout() { 4 | return ( 5 | 6 | 13 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | setupFilesAfterEnv: ['/jest-setup.js'], 4 | moduleNameMapper: { 5 | '^@/(.*)$': '/$1', 6 | }, 7 | transform: { 8 | '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', 9 | }, 10 | // Allow transforming a few ESM packages used by Expo and RN Firebase 11 | transformIgnorePatterns: [ 12 | 'node_modules/(?!(react-native|@react-native|@react-native-firebase|expo-image-picker|expo-modules-core|expo-modules-autolinking|expo-.*)/)', 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | .kotlin/ 14 | *.orig.* 15 | *.jks 16 | *.p8 17 | *.p12 18 | *.key 19 | *.mobileprovision 20 | 21 | # Metro 22 | .metro-health-check* 23 | 24 | # debug 25 | npm-debug.* 26 | yarn-debug.* 27 | yarn-error.* 28 | 29 | # macOS 30 | .DS_Store 31 | *.pem 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | # generated native folders 40 | /ios 41 | /android 42 | -------------------------------------------------------------------------------- /firebase/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "1081874627220", 4 | "project_id": "asset-demo-app", 5 | "storage_bucket": "asset-demo-app.firebasestorage.app" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:1081874627220:android:21969f12940421fb5d9281", 11 | "android_client_info": { 12 | "package_name": "com.demoapp" 13 | } 14 | }, 15 | "oauth_client": [], 16 | "api_key": [ 17 | { 18 | "current_key": "AIzaSyAF70W8EqkdjRLcEr2fdWd0Q4e2DjQ1j_c" 19 | } 20 | ], 21 | "services": { 22 | "appinvite_service": { 23 | "other_platform_oauth_client": [] 24 | } 25 | } 26 | } 27 | ], 28 | "configuration_version": "1" 29 | } -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 13.2.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal", 9 | "ios": { 10 | "simulator": true 11 | }, 12 | "android": { 13 | "buildType": "apk" 14 | } 15 | }, 16 | "preview": { 17 | "distribution": "internal", 18 | "ios": { 19 | "simulator": false 20 | }, 21 | "android": { 22 | "buildType": "apk" 23 | } 24 | }, 25 | "production": { 26 | "ios": { 27 | "simulator": false 28 | }, 29 | "android": { 30 | "buildType": "apk" 31 | } 32 | } 33 | }, 34 | "submit": { 35 | "production": {} 36 | } 37 | } -------------------------------------------------------------------------------- /firebase/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_KEY 6 | AIzaSyCwFBvQqwEyH02MKfbsAVNjvMR7KJMSY4E 7 | GCM_SENDER_ID 8 | 1081874627220 9 | PLIST_VERSION 10 | 1 11 | BUNDLE_ID 12 | asset-demo 13 | PROJECT_ID 14 | asset-demo-app 15 | STORAGE_BUCKET 16 | asset-demo-app.firebasestorage.app 17 | IS_ADS_ENABLED 18 | 19 | IS_ANALYTICS_ENABLED 20 | 21 | IS_APPINVITE_ENABLED 22 | 23 | IS_GCM_ENABLED 24 | 25 | IS_SIGNIN_ENABLED 26 | 27 | GOOGLE_APP_ID 28 | 1:1081874627220:ios:d452aa58ca4c11365d9281 29 | 30 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from 'expo-router'; 2 | import { StyleSheet } from 'react-native'; 3 | 4 | import { Text, View } from '@/components/Themed'; 5 | 6 | export default function NotFoundScreen() { 7 | return ( 8 | <> 9 | 10 | 11 | This screen doesn't exist. 12 | 13 | 14 | Go to home screen! 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | container: { 23 | flex: 1, 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | padding: 20, 27 | }, 28 | title: { 29 | fontSize: 20, 30 | fontWeight: 'bold', 31 | }, 32 | link: { 33 | marginTop: 15, 34 | paddingVertical: 15, 35 | }, 36 | linkText: { 37 | fontSize: 14, 38 | color: '#2e78b7', 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Demo App — Expo + Firebase 2 | 3 | Minimal Expo app demonstrating Firebase Authentication and Firestore asset CRUD. 4 | 5 | ## App Tour 6 | 7 | 8 | 9 | ## Key features 10 | - [x] Login handling. 11 | - [x] Logout handling. 12 | - [x] Create, read, update, delete Firestore documents (assets). 13 | - [x] Assets support 1+ camera images. 14 | - [x] Asset documents include `name`/`description`, `createdAt`, and `images`. 15 | - [x] View a list of all assets. 16 | - [x] View and update details of a specific asset. 17 | 18 | 19 | ## Firestore architecture 20 | 21 | - **Context**: Provides a real-time list of all assets for screens like asset listings 22 | - **Individual subscriptions**: Ensure single-asset views stay in sync even if context hasn’t updated yet 23 | - **Automatic cleanup**: Both subscriptions auto-unsubscribe when components unmount 24 | 25 | Provides efficient bulk operations and guaranteed real-time individual document updates. 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tsParser from '@typescript-eslint/parser'; 2 | 3 | export default [ 4 | { 5 | files: ['**/*.{js,jsx}'], 6 | languageOptions: { 7 | ecmaVersion: 2020, 8 | sourceType: 'module', 9 | globals: { 10 | console: 'readonly', 11 | __DEV__: 'readonly', 12 | }, 13 | }, 14 | rules: { 15 | 'no-unused-vars': 'warn', 16 | }, 17 | }, 18 | { 19 | files: ['**/*.{ts,tsx}'], 20 | languageOptions: { 21 | parser: tsParser, 22 | ecmaVersion: 2020, 23 | sourceType: 'module', 24 | parserOptions: { 25 | ecmaFeatures: { 26 | jsx: true, 27 | }, 28 | }, 29 | globals: { 30 | console: 'readonly', 31 | __DEV__: 'readonly', 32 | }, 33 | }, 34 | rules: { 35 | 'no-unused-vars': 'off', 36 | }, 37 | }, 38 | { 39 | ignores: [ 40 | 'node_modules/', 41 | 'android/', 42 | 'ios/', 43 | '.expo/', 44 | 'jest-setup.js', 45 | '*.config.js', 46 | '*.config.cjs' 47 | ], 48 | }, 49 | ]; 50 | -------------------------------------------------------------------------------- /components/LoadingState/__tests__/LoadingState.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react-native'; 2 | import React from 'react'; 3 | 4 | import { LoadingState } from '../LoadingState'; 5 | 6 | describe('LoadingState', () => { 7 | it('renders without crashing', () => { 8 | const { getByTestId } = render(); 9 | expect(getByTestId('loading-indicator')).toBeTruthy(); 10 | }); 11 | 12 | it('shows message when showMessage is true', () => { 13 | const { getByText } = render(); 14 | expect(getByText('Please wait')).toBeTruthy(); 15 | }); 16 | 17 | it('does not show message when showMessage is false', () => { 18 | const { queryByText } = render(); 19 | expect(queryByText('Hidden')).toBeNull(); 20 | }); 21 | 22 | it('passes size prop to ActivityIndicator', () => { 23 | const { getByTestId } = render(); 24 | const indicator = getByTestId('loading-indicator'); 25 | expect(indicator.props.size).toBe('small'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /components/LoadingState/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | import { ActivityIndicator, StyleSheet } from 'react-native'; 2 | import { Text } from 'react-native-paper'; 3 | 4 | import { View } from '@/components/Themed'; 5 | 6 | interface LoadingStateProps { 7 | message?: string; 8 | size?: 'small' | 'large'; 9 | style?: object; 10 | showMessage?: boolean; 11 | } 12 | 13 | export function LoadingState({ 14 | message = 'Loading...', 15 | size = 'large', 16 | style, 17 | showMessage = false, 18 | }: LoadingStateProps) { 19 | return ( 20 | 21 | 22 | {showMessage && ( 23 | 24 | {message} 25 | 26 | )} 27 | 28 | ); 29 | } 30 | 31 | const styles = StyleSheet.create({ 32 | container: { 33 | flex: 1, 34 | justifyContent: 'center', 35 | alignItems: 'center', 36 | padding: 32, 37 | }, 38 | indicator: { 39 | marginBottom: 16, 40 | }, 41 | message: { 42 | textAlign: 'center', 43 | opacity: 0.7, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /components/ErrorBoundary/__tests__/ErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react-native'; 2 | import React from 'react'; 3 | import { ErrorBoundary } from '../ErrorBoundary'; 4 | 5 | const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => { 6 | if (shouldThrow) { 7 | throw new Error('Test error'); 8 | } 9 | return <>; 10 | }; 11 | 12 | describe('ErrorBoundary', () => { 13 | const originalConsoleError = console.error; 14 | beforeAll(() => { 15 | console.error = jest.fn(); 16 | }); 17 | afterAll(() => { 18 | console.error = originalConsoleError; 19 | }); 20 | 21 | it('renders children when there is no error', () => { 22 | const { getByText } = render( 23 | 24 | 25 | <>{/* Text component */} 26 | 27 | ); 28 | 29 | // Should not show error UI 30 | expect(() => getByText('Something went wrong')).toThrow(); 31 | }); 32 | 33 | it('renders error UI when child component throws', () => { 34 | const { getByText } = render( 35 | 36 | 37 | 38 | ); 39 | 40 | expect(getByText('Something went wrong')).toBeTruthy(); 41 | expect(getByText('Please try again')).toBeTruthy(); 42 | expect(getByText('Try Again')).toBeTruthy(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /components/EmptyState/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | import { Icon, Text } from 'react-native-paper'; 3 | 4 | import { View } from '@/components/Themed'; 5 | 6 | interface EmptyStateProps { 7 | title?: string; 8 | subtitle?: string; 9 | icon?: string; 10 | iconSize?: number; 11 | textVariant?: 'bodyLarge' | 'bodyMedium' | 'headlineSmall' | 'headlineMedium'; 12 | style?: object; 13 | } 14 | 15 | export function EmptyState({ 16 | title = 'No data found', 17 | subtitle, 18 | icon = 'information-outline', 19 | iconSize = 48, 20 | textVariant = 'bodyLarge', 21 | style, 22 | }: EmptyStateProps) { 23 | return ( 24 | 25 | 26 | 27 | {title} 28 | 29 | {subtitle && ( 30 | 31 | {subtitle} 32 | 33 | )} 34 | 35 | ); 36 | } 37 | 38 | const styles = StyleSheet.create({ 39 | container: { 40 | flex: 1, 41 | justifyContent: 'center', 42 | alignItems: 'center', 43 | padding: 32, 44 | }, 45 | title: { 46 | textAlign: 'center', 47 | marginTop: 16, 48 | opacity: 0.7, 49 | }, 50 | subtitle: { 51 | textAlign: 'center', 52 | marginTop: 8, 53 | opacity: 0.5, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /components/EmptyState/__tests__/EmptyState.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react-native'; 2 | import React from 'react'; 3 | import { EmptyState } from '../EmptyState'; 4 | 5 | jest.mock('react-native-paper', () => { 6 | const React = require('react'); 7 | return { 8 | Text: ({ children, ...props }: any) => React.createElement('Text', props, children), 9 | Icon: ({ source, size }: any) => 10 | React.createElement('Text', { testID: 'icon', children: `${source}:${size}` }), 11 | }; 12 | }); 13 | 14 | jest.mock('@/components/Themed', () => { 15 | const React = require('react'); 16 | return { 17 | View: ({ children, ...props }: any) => React.createElement('View', props, children), 18 | }; 19 | }); 20 | 21 | describe('EmptyState', () => { 22 | it('renders default title', () => { 23 | const { getByText } = render(); 24 | expect(getByText('No data found')).toBeTruthy(); 25 | }); 26 | 27 | it('renders custom title and subtitle', () => { 28 | const { getByText } = render(); 29 | expect(getByText('Empty')).toBeTruthy(); 30 | expect(getByText('Try again')).toBeTruthy(); 31 | }); 32 | 33 | it('renders icon with provided name and size', () => { 34 | const { getByTestId } = render(); 35 | const icon = getByTestId('icon'); 36 | expect(icon).toBeTruthy(); 37 | expect(icon.props.children).toBe('check:32'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /components/Themed.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about Light and Dark modes: 3 | * https://docs.expo.io/guides/color-schemes/ 4 | */ 5 | 6 | import { Text as DefaultText, View as DefaultView } from 'react-native'; 7 | 8 | import Colors from '@/constants/Colors'; 9 | import { useColorScheme } from './useColorScheme'; 10 | 11 | type ThemeProps = { 12 | lightColor?: string; 13 | darkColor?: string; 14 | }; 15 | 16 | export type TextProps = ThemeProps & DefaultText['props']; 17 | export type ViewProps = ThemeProps & DefaultView['props']; 18 | 19 | export function useThemeColor( 20 | props: { light?: string; dark?: string }, 21 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 22 | ) { 23 | const theme = useColorScheme() ?? 'light'; 24 | const colorFromProps = props[theme]; 25 | 26 | if (colorFromProps) { 27 | return colorFromProps; 28 | } else { 29 | return Colors[theme][colorName]; 30 | } 31 | } 32 | 33 | export function Text(props: TextProps) { 34 | const { style, lightColor, darkColor, ...otherProps } = props; 35 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 36 | 37 | return ; 38 | } 39 | 40 | export function View(props: ViewProps) { 41 | const { style, lightColor, darkColor, ...otherProps } = props; 42 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 43 | 44 | return ; 45 | } 46 | -------------------------------------------------------------------------------- /app/+html.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollViewStyleReset } from 'expo-router/html'; 2 | 3 | // This file is web-only and used to configure the root HTML for every 4 | // web page during static rendering. 5 | // The contents of this function only run in Node.js environments and 6 | // do not have access to the DOM or browser APIs. 7 | export default function Root({ children }: { children: React.ReactNode }) { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | {/* 16 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. 17 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. 18 | */} 19 | 20 | 21 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} 22 |