├── 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 |
23 | {/* Add any additional elements that you want globally available on web... */}
24 |
25 | {children}
26 |
27 | );
28 | }
29 |
30 | const responsiveBackground = `
31 | body {
32 | background-color: #fff;
33 | }
34 | @media (prefers-color-scheme: dark) {
35 | body {
36 | background-color: #000;
37 | }
38 | }`;
39 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "assets-survey-demo",
4 | "slug": "demo-app",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "assets-survey-demo",
9 | "userInterfaceStyle": "automatic",
10 | "newArchEnabled": true,
11 | "splash": {
12 | "image": "./assets/images/splash-icon.png",
13 | "resizeMode": "contain",
14 | "backgroundColor": "#00C1EB"
15 | },
16 | "ios": {
17 | "supportsTablet": true,
18 | "bundleIdentifier": "com.skempin.demoapp",
19 | "googleServicesFile": "./firebase/GoogleService-Info.plist",
20 | "infoPlist": {
21 | "ITSAppUsesNonExemptEncryption": false
22 | }
23 | },
24 | "android": {
25 | "adaptiveIcon": {
26 | "foregroundImage": "./assets/images/adaptive-icon.png",
27 | "backgroundColor": "#00C1EB"
28 | },
29 | "googleServicesFile": "./firebase/google-services.json",
30 | "edgeToEdgeEnabled": true,
31 | "predictiveBackGestureEnabled": false,
32 | "package": "com.skempin.demoapp"
33 | },
34 | "web": {
35 | "bundler": "metro",
36 | "output": "static",
37 | "favicon": "./assets/images/favicon.png"
38 | },
39 | "plugins": [
40 | "expo-router",
41 | "@react-native-firebase/app",
42 | "@react-native-firebase/auth",
43 | [
44 | "expo-build-properties",
45 | {
46 | "ios": {
47 | "useFrameworks": "static",
48 | "buildReactNativeFromSource": true
49 | }
50 | }
51 | ]
52 | ],
53 | "experiments": {
54 | "typedRoutes": true
55 | },
56 | "extra": {
57 | "router": {},
58 | "eas": {
59 | "projectId": "2ac654ba-44e5-44b0-9feb-924f00bc00e7"
60 | }
61 | },
62 | "owner": "skempin"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/components/ErrorBoundary/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Pressable, StyleSheet, Text, View } from 'react-native';
3 |
4 | interface Props {
5 | children: React.ReactNode;
6 | }
7 |
8 | interface State {
9 | hasError: boolean;
10 | }
11 |
12 | export class ErrorBoundary extends React.Component {
13 | constructor(props: Props) {
14 | super(props);
15 | this.state = { hasError: false };
16 | }
17 |
18 | static getDerivedStateFromError(): State {
19 | return { hasError: true };
20 | }
21 |
22 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
23 | console.error('ErrorBoundary caught an error:', error, errorInfo);
24 | }
25 |
26 | resetError = () => {
27 | this.setState({ hasError: false });
28 | };
29 |
30 | render() {
31 | if (this.state.hasError) {
32 | return (
33 |
34 | Something went wrong
35 | Please try again
36 |
37 | Try Again
38 |
39 |
40 | );
41 | }
42 |
43 | return this.props.children;
44 | }
45 | }
46 |
47 | const styles = StyleSheet.create({
48 | container: {
49 | flex: 1,
50 | justifyContent: 'center',
51 | alignItems: 'center',
52 | padding: 20,
53 | },
54 | title: {
55 | fontSize: 20,
56 | fontWeight: 'bold',
57 | marginBottom: 10,
58 | },
59 | message: {
60 | fontSize: 16,
61 | marginBottom: 20,
62 | textAlign: 'center',
63 | },
64 | button: {
65 | backgroundColor: '#007AFF',
66 | paddingHorizontal: 20,
67 | paddingVertical: 10,
68 | borderRadius: 5,
69 | },
70 | buttonText: {
71 | color: 'white',
72 | fontWeight: 'bold',
73 | },
74 | });
75 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import FontAwesome from '@expo/vector-icons/FontAwesome';
2 | import { Tabs } from 'expo-router';
3 | import React from 'react';
4 |
5 | import { useClientOnlyValue } from '@/components/useClientOnlyValue';
6 | import { useColorScheme } from '@/components/useColorScheme';
7 | import Colors from '@/constants/Colors';
8 |
9 | // You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
10 | function TabBarIcon(props: {
11 | name: React.ComponentProps['name'];
12 | color: string;
13 | }) {
14 | return ;
15 | }
16 |
17 | export default function TabLayout() {
18 | const colorScheme = useColorScheme();
19 |
20 | return (
21 |
28 | ,
33 | }}
34 | />
35 | ,
43 | }}
44 | />
45 | ,
50 | }}
51 | />
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/app/(tabs)/(my-assets)/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'expo-router';
2 | import { FlatList, StyleSheet } from 'react-native';
3 | import { Divider, List, Text } from 'react-native-paper';
4 |
5 | import { LoadingState } from '@/components/LoadingState';
6 | import { View } from '@/components/Themed';
7 | import { useFirestore } from '@/context/FirestoreContext';
8 |
9 | export default function MyAssetsScreen() {
10 | const { assets, loading } = useFirestore();
11 | const router = useRouter();
12 |
13 | if (loading) {
14 | return ;
15 | }
16 |
17 | const renderAssetItem = ({ item }) => (
18 | }
22 | right={(props) => }
23 | descriptionNumberOfLines={3}
24 | onPress={() => router.push(`/(tabs)/(my-assets)/details/${item.id}`)}
25 | />
26 | );
27 |
28 | const renderEmptyState = () => (
29 |
30 |
31 | No assets found
32 |
33 |
34 | );
35 |
36 | return (
37 |
38 | item.id}
42 | ItemSeparatorComponent={() => }
43 | ListEmptyComponent={renderEmptyState}
44 | contentContainerStyle={assets.length === 0 ? styles.emptyContentContainer : undefined}
45 | />
46 |
47 | );
48 | }
49 |
50 | const styles = StyleSheet.create({
51 | container: {
52 | flex: 1,
53 | },
54 | loader: {
55 | marginTop: 50,
56 | },
57 | emptyContentContainer: {
58 | flex: 1,
59 | },
60 | emptyContainer: {
61 | flex: 1,
62 | justifyContent: 'center',
63 | alignItems: 'center',
64 | paddingTop: 50,
65 | },
66 | emptyText: {
67 | opacity: 0.5,
68 | },
69 | });
70 |
--------------------------------------------------------------------------------
/context/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | import { getApp } from '@react-native-firebase/app';
2 | import {
3 | FirebaseAuthTypes,
4 | getAuth,
5 | onAuthStateChanged,
6 | signInWithEmailAndPassword,
7 | signOut,
8 | } from '@react-native-firebase/auth';
9 | import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
10 |
11 | interface AuthContextType {
12 | isAuthenticated: boolean;
13 | user: string | null;
14 | currentUser: FirebaseAuthTypes.User | null;
15 | login: (email: string, password: string) => Promise;
16 | logout: () => Promise;
17 | loading: boolean;
18 | }
19 |
20 | const AuthContext = createContext(undefined);
21 |
22 | export function AuthProvider({ children }: { children: ReactNode }) {
23 | const [currentUser, setCurrentUser] = useState(null);
24 | const [loading, setLoading] = useState(true);
25 |
26 | const app = getApp();
27 | const auth = getAuth(app);
28 |
29 | useEffect(() => {
30 | const unsubscribe = onAuthStateChanged(auth, (user) => {
31 | setCurrentUser(user);
32 | setLoading(false);
33 | });
34 |
35 | return unsubscribe;
36 | }, []);
37 |
38 | const login = async (email: string, password: string): Promise => {
39 | try {
40 | await signInWithEmailAndPassword(auth, email, password);
41 | return true;
42 | } catch (error) {
43 | console.error('Login error:', error);
44 | return false;
45 | }
46 | };
47 |
48 | const logout = async () => {
49 | try {
50 | await signOut(auth);
51 | } catch (error) {
52 | console.error('Logout error:', error);
53 | }
54 | };
55 |
56 | const value = {
57 | isAuthenticated: !!currentUser,
58 | user: currentUser?.email || null,
59 | currentUser,
60 | login,
61 | logout,
62 | loading,
63 | };
64 |
65 | return {children};
66 | }
67 |
68 | export function useAuth() {
69 | const context = useContext(AuthContext);
70 | if (context === undefined) {
71 | throw new Error('useAuth must be used within an AuthProvider');
72 | }
73 | return context;
74 | }
75 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "assets-survey-demo",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "test": "jest",
11 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
12 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
13 | },
14 | "dependencies": {
15 | "@expo/vector-icons": "^15.0.3",
16 | "@react-native-firebase/app": "^23.7.0",
17 | "@react-native-firebase/auth": "^23.7.0",
18 | "@react-native-firebase/firestore": "^23.7.0",
19 | "@react-native-firebase/storage": "^23.7.0",
20 | "@react-native-vector-icons/material-design-icons": "^12.4.0",
21 | "@react-navigation/native": "^7.1.8",
22 | "expo": "~54.0.29",
23 | "expo-build-properties": "~1.0.10",
24 | "expo-constants": "~18.0.11",
25 | "expo-dev-client": "~6.0.20",
26 | "expo-file-system": "~19.0.20",
27 | "expo-font": "~14.0.10",
28 | "expo-image-picker": "~17.0.10",
29 | "expo-linking": "~8.0.10",
30 | "expo-router": "~6.0.19",
31 | "expo-splash-screen": "~31.0.12",
32 | "expo-status-bar": "~3.0.9",
33 | "expo-web-browser": "~15.0.10",
34 | "install": "^0.13.0",
35 | "npx": "^10.2.2",
36 | "react-dom": "19.1.0",
37 | "react-hook-form": "^7.68.0",
38 | "react-native": "0.81.5",
39 | "react-native-paper": "^5.14.5",
40 | "react-native-reanimated": "~4.1.1",
41 | "react-native-safe-area-context": "~5.6.0",
42 | "react-native-screens": "~4.16.0",
43 | "react-native-web": "~0.21.0",
44 | "react-native-worklets": "0.5.1"
45 | },
46 | "devDependencies": {
47 | "@eslint/js": "^9.39.2",
48 | "@react-native/babel-preset": "^0.83.0",
49 | "@testing-library/jest-native": "^5.4.3",
50 | "@testing-library/react-native": "^13.3.3",
51 | "@types/jest": "^30.0.0",
52 | "@types/react": "~19.1.0",
53 | "@typescript-eslint/eslint-plugin": "^8.49.0",
54 | "@typescript-eslint/parser": "^8.49.0",
55 | "babel-jest": "^30.2.0",
56 | "eslint": "^9.39.2",
57 | "eslint-plugin-react": "^7.37.5",
58 | "eslint-plugin-react-hooks": "^7.0.1",
59 | "eslint-plugin-react-native": "^5.0.0",
60 | "jest": "^30.2.0",
61 | "react": "19.1.0",
62 | "react-test-renderer": "19.1.0",
63 | "ts-jest": "^29.4.6",
64 | "typescript": "~5.9.2"
65 | },
66 | "private": true
67 | }
68 |
--------------------------------------------------------------------------------
/app/(tabs)/settings.tsx:
--------------------------------------------------------------------------------
1 | import Constants from 'expo-constants';
2 | import { Alert, Linking, Platform, StyleSheet } from 'react-native';
3 | import { Button, Divider, List, Text } from 'react-native-paper';
4 |
5 | import { View } from '@/components/Themed';
6 | import { useAuth } from '@/context/AuthContext';
7 |
8 | export default function SettingsScreen() {
9 | const { logout, user } = useAuth();
10 |
11 | const handleLogout = () => {
12 | Alert.alert(
13 | 'Logout',
14 | 'Are you sure you want to logout?',
15 | [
16 | {
17 | text: 'Cancel',
18 | style: 'cancel',
19 | },
20 | {
21 | text: 'Logout',
22 | style: 'destructive',
23 | onPress: logout,
24 | },
25 | ],
26 | { cancelable: true }
27 | );
28 | };
29 |
30 | const storeURL = () => {
31 | const url =
32 | Platform.OS === 'ios'
33 | ? 'https://apps.apple.com/gb/developer/stephen-kempin/id1451415928'
34 | : 'https://play.google.com/store/apps/developer?id=SK+UK+Digital';
35 | Linking.openURL(url);
36 | };
37 |
38 | return (
39 |
40 |
41 | Account
42 | } />
43 |
44 |
45 | About
46 | }
49 | onPress={storeURL}
50 | />
51 |
52 |
53 |
54 |
55 |
58 |
59 | Version {Constants.expoConfig?.version || '1.0.0'}
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | const styles = StyleSheet.create({
67 | container: {
68 | flex: 1,
69 | padding: 16,
70 | },
71 | title: {
72 | fontSize: 20,
73 | fontWeight: 'bold',
74 | textAlign: 'center',
75 | marginVertical: 16,
76 | },
77 | buttonContainer: {
78 | padding: 16,
79 | marginTop: 'auto',
80 | },
81 | button: {
82 | marginVertical: 8,
83 | },
84 | version: {
85 | textAlign: 'center',
86 | marginTop: 16,
87 | opacity: 0.5,
88 | },
89 | });
90 |
--------------------------------------------------------------------------------
/components/AssetForm/__tests__/AssetForm.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render, waitFor } from '@testing-library/react-native';
2 | import React from 'react';
3 |
4 | import { FirestoreContext } from '@/context/FirestoreContext';
5 |
6 | const createAssetMock = jest.fn();
7 | const updateAssetMock = jest.fn();
8 |
9 | import AssetForm from '../AssetForm';
10 |
11 | describe('AssetForm', () => {
12 | beforeEach(() => {
13 | createAssetMock.mockReset();
14 | updateAssetMock.mockReset();
15 | });
16 |
17 | it('renders form fields and buttons', () => {
18 | const mockFirestore = {
19 | assets: [],
20 | loading: false,
21 | createAsset: createAssetMock,
22 | updateAsset: updateAssetMock,
23 | deleteAsset: jest.fn(),
24 | getAsset: jest.fn(),
25 | subscribeToAsset: jest.fn(),
26 | } as any;
27 |
28 | const { getByTestId, getByText } = render(
29 |
30 |
31 |
32 | );
33 |
34 | expect(getByTestId('input-Name')).toBeTruthy();
35 | expect(getByTestId('input-Description')).toBeTruthy();
36 | expect(getByTestId('input-Location')).toBeTruthy();
37 |
38 | expect(getByText('Add Attachment')).toBeTruthy();
39 | expect(getByText('Save Asset')).toBeTruthy();
40 | });
41 |
42 | it('submits new asset when required fields are provided', async () => {
43 | createAssetMock.mockResolvedValueOnce('new-id');
44 |
45 | const mockFirestore = {
46 | assets: [],
47 | loading: false,
48 | createAsset: createAssetMock,
49 | updateAsset: updateAssetMock,
50 | deleteAsset: jest.fn(),
51 | getAsset: jest.fn(),
52 | subscribeToAsset: jest.fn(),
53 | } as any;
54 |
55 | const { getByTestId, getByText } = render(
56 |
57 |
58 |
59 | );
60 |
61 | const nameInput = getByTestId('input-Name');
62 | const descInput = getByTestId('input-Description');
63 |
64 | fireEvent.changeText(nameInput, ' My Asset ');
65 | fireEvent.changeText(descInput, 'A description');
66 |
67 | fireEvent.press(getByText('Save Asset'));
68 |
69 | await waitFor(() => expect(createAssetMock).toHaveBeenCalled());
70 |
71 | const calledWith = createAssetMock.mock.calls[0][0];
72 | expect(calledWith.name).toBe('My Asset');
73 | expect(calledWith.description).toBe('A description');
74 | expect(calledWith.attachments).toEqual([]);
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/jest-setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-native/extend-expect';
2 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
3 |
4 | // Reanimated (only if used)
5 | jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
6 |
7 | // Mock expo-image-picker to avoid ESM import issues in Jest
8 | jest.mock('expo-image-picker', () => ({
9 | requestCameraPermissionsAsync: jest.fn().mockResolvedValue({ granted: true }),
10 | launchCameraAsync: jest.fn().mockResolvedValue({ canceled: true }),
11 | requestMediaLibraryPermissionsAsync: jest.fn().mockResolvedValue({ granted: true }),
12 | launchImageLibraryAsync: jest.fn().mockResolvedValue({ canceled: true, assets: [] }),
13 | }));
14 |
15 | // Mock react-native-firebase modules to avoid importing ESM native builds in Jest
16 | jest.mock('@react-native-firebase/app', () => ({
17 | getApp: jest.fn(() => ({})),
18 | }));
19 |
20 | jest.mock('@react-native-firebase/firestore', () => ({
21 | getFirestore: jest.fn(() => ({})),
22 | collection: jest.fn(() => ({})),
23 | addDoc: jest.fn(async () => ({ id: 'mock-id' })),
24 | deleteDoc: jest.fn(async () => {}),
25 | doc: jest.fn(() => ({})),
26 | getDoc: jest.fn(async () => ({ exists: () => false })),
27 | onSnapshot: jest.fn((_ref, success, _error) => {
28 | // call success with empty snapshot-like object
29 | const unsubscribe = () => {};
30 | setTimeout(() => success({ docs: [] }), 0);
31 | return unsubscribe;
32 | }),
33 | updateDoc: jest.fn(async () => {}),
34 | }));
35 |
36 | jest.mock('@react-native-firebase/auth', () => ({
37 | getAuth: jest.fn(() => ({})),
38 | onAuthStateChanged: jest.fn(() => () => {}),
39 | signInWithEmailAndPassword: jest.fn(async () => ({})),
40 | createUserWithEmailAndPassword: jest.fn(async () => ({})),
41 | }));
42 |
43 | // Mock react-native-paper components
44 | jest.mock('react-native-paper', () => {
45 | const React = require('react');
46 | const RN = require('react-native');
47 | return {
48 | Button: ({ onPress, children, disabled }: any) =>
49 | React.createElement(
50 | 'Pressable',
51 | { onPress, accessibilityState: { disabled } },
52 | React.createElement('Text', null, typeof children === 'string' ? children : children)
53 | ),
54 | TextInput: ({ label, value, onChangeText, ...props }: any) =>
55 | React.createElement(RN.TextInput, {
56 | accessibilityLabel: label,
57 | testID: label
58 | ? `input-${String(label).replace(/\*/g, '').trim().replace(/\s+/g, '-')}`
59 | : undefined,
60 | value,
61 | onChangeText,
62 | ...props,
63 | }),
64 | HelperText: ({ children }: any) => React.createElement('Text', null, children),
65 | IconButton: ({ icon, onPress }: any) =>
66 | React.createElement('Pressable', { onPress }, React.createElement('Text', null, icon)),
67 | Text: ({ children }: any) => React.createElement('Text', null, children),
68 | Divider: ({ children }: any) => React.createElement('Text', null, children),
69 | };
70 | });
71 |
72 | // Mock Themed components
73 | jest.mock('@/components/Themed', () => {
74 | const React = require('react');
75 | const RN = require('react-native');
76 | return { View: (props: any) => React.createElement(RN.View, props, props.children) };
77 | });
78 |
79 | // Mock expo-router
80 | jest.mock('expo-router', () => ({ useRouter: () => ({ push: jest.fn(), back: jest.fn() }) }));
81 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import FontAwesome from '@expo/vector-icons/FontAwesome';
2 | import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
3 | import { useFonts } from 'expo-font';
4 | import { Stack, useRouter, useSegments } from 'expo-router';
5 | import * as SplashScreen from 'expo-splash-screen';
6 | import { useEffect, useMemo } from 'react';
7 | import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper';
8 | import 'react-native-reanimated';
9 |
10 | import { ErrorBoundary } from '@/components/ErrorBoundary';
11 | import { useColorScheme } from '@/components/useColorScheme';
12 | import { AuthProvider, useAuth } from '@/context/AuthContext';
13 | import { FirestoreProvider } from '@/context/FirestoreContext';
14 |
15 | export { ErrorBoundary } from 'expo-router';
16 |
17 | export const unstable_settings = {
18 | // Ensure that reloading on `/modal` keeps a back button present.
19 | initialRouteName: '(tabs)',
20 | };
21 |
22 | SplashScreen.preventAutoHideAsync();
23 |
24 | export default function RootLayout() {
25 | const [loaded, error] = useFonts({
26 | SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
27 | ...FontAwesome.font,
28 | });
29 |
30 | useEffect(() => {
31 | if (error) throw error;
32 | }, [error]);
33 |
34 | useEffect(() => {
35 | if (loaded) {
36 | SplashScreen.hideAsync();
37 | }
38 | }, [loaded]);
39 |
40 | if (!loaded) {
41 | return null;
42 | }
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | function RootLayoutNav() {
56 | const colorScheme = useColorScheme();
57 | const { isAuthenticated, loading } = useAuth();
58 | const segments = useSegments();
59 | const router = useRouter();
60 |
61 | const paperTheme = useMemo(
62 | () =>
63 | colorScheme === 'dark'
64 | ? {
65 | ...MD3DarkTheme,
66 | colors: {
67 | ...MD3DarkTheme.colors,
68 | primary: '#2196F3',
69 | primaryContainer: '#BBDEFB',
70 | },
71 | }
72 | : {
73 | ...MD3LightTheme,
74 | colors: {
75 | ...MD3LightTheme.colors,
76 | primary: '#2196F3',
77 | primaryContainer: '#BBDEFB',
78 | },
79 | },
80 | [colorScheme]
81 | );
82 |
83 | useEffect(() => {
84 | if (loading) return;
85 |
86 | const inAuthGroup = segments[0] === '(tabs)';
87 |
88 | if (!isAuthenticated && inAuthGroup) {
89 | // Redirect to login if not authenticated and trying to access protected routes
90 | router.replace('/(auth)/login');
91 | } else if (isAuthenticated && segments[0] === '(auth)') {
92 | // Redirect to tabs if authenticated and on login screen
93 | router.replace('/(tabs)');
94 | }
95 | }, [isAuthenticated, segments, loading]);
96 |
97 | return (
98 |
99 |
100 |
101 |
109 |
117 |
118 |
119 |
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/app/(tabs)/(my-assets)/edit/[id].tsx:
--------------------------------------------------------------------------------
1 | import { router, Stack, useLocalSearchParams } from 'expo-router';
2 | import { useEffect, useState } from 'react';
3 | import { Alert, StyleSheet } from 'react-native';
4 | import { Button } from 'react-native-paper';
5 |
6 | import AssetForm from '@/components/AssetForm';
7 | import { EmptyState } from '@/components/EmptyState';
8 | import { LoadingState } from '@/components/LoadingState';
9 | import { View } from '@/components/Themed';
10 | import { useFirestore } from '@/context/FirestoreContext';
11 | import { Asset } from '@/types/Asset';
12 |
13 | export default function EditAssetScreen() {
14 | const { id } = useLocalSearchParams();
15 | const [asset, setAsset] = useState(null);
16 | const [loading, setLoading] = useState(true);
17 | const [deleting, setDeleting] = useState(false);
18 | const { getAsset, deleteAsset } = useFirestore();
19 |
20 | useEffect(() => {
21 | const fetchAsset = async () => {
22 | if (!id) {
23 | setLoading(false);
24 | return;
25 | }
26 |
27 | try {
28 | const assetData = await getAsset(id as string);
29 | setAsset(assetData);
30 | } catch (error) {
31 | console.error('Error fetching asset:', error);
32 | } finally {
33 | setLoading(false);
34 | }
35 | };
36 |
37 | fetchAsset();
38 | }, [id, getAsset]);
39 |
40 | const handleDelete = async () => {
41 | if (!id) return;
42 |
43 | Alert.alert(
44 | 'Delete Asset',
45 | 'Are you sure you want to delete this asset? This action cannot be undone.',
46 | [
47 | {
48 | text: 'Cancel',
49 | style: 'cancel',
50 | },
51 | {
52 | text: 'Delete',
53 | style: 'destructive',
54 | onPress: async () => {
55 | try {
56 | setDeleting(true);
57 | await deleteAsset(id as string);
58 | router.back();
59 | router.back();
60 | } catch (error) {
61 | console.error('Error deleting asset:', error);
62 | Alert.alert('Error', 'Failed to delete asset. Please try again.');
63 | } finally {
64 | setDeleting(false);
65 | }
66 | },
67 | },
68 | ]
69 | );
70 | };
71 |
72 | if (loading) {
73 | return (
74 | <>
75 |
76 |
77 | >
78 | );
79 | }
80 |
81 | if (!asset) {
82 | return ;
83 | }
84 |
85 | return (
86 | <>
87 |
88 |
89 |
99 |
100 |
110 |
111 |
112 | >
113 | );
114 | }
115 |
116 | const styles = StyleSheet.create({
117 | container: {
118 | flex: 1,
119 | },
120 | emptyText: {
121 | textAlign: 'center',
122 | marginTop: 50,
123 | opacity: 0.5,
124 | },
125 | footer: {
126 | padding: 16,
127 | paddingBottom: 32,
128 | },
129 | deleteButton: {
130 | borderColor: '#dc2626',
131 | paddingVertical: 6,
132 | },
133 | });
134 |
--------------------------------------------------------------------------------
/app/(auth)/login.tsx:
--------------------------------------------------------------------------------
1 | import { useAuth } from '@/context/AuthContext';
2 | import { router } from 'expo-router';
3 | import React, { useState } from 'react';
4 | import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
5 | import { Button, HelperText, Text, TextInput } from 'react-native-paper';
6 |
7 | export default function LoginScreen() {
8 | const [email, setEmail] = useState('');
9 | const [password, setPassword] = useState('');
10 | const [loading, setLoading] = useState(false);
11 | const [error, setError] = useState('');
12 | const [showPassword, setShowPassword] = useState(false);
13 | const { login } = useAuth();
14 |
15 | const handleLogin = async () => {
16 | setError('');
17 |
18 | if (!email || !password) {
19 | setError('Please enter both email and password');
20 | return;
21 | }
22 |
23 | setLoading(true);
24 | try {
25 | const success = await login(email, password);
26 | if (success) {
27 | router.replace('/(tabs)');
28 | } else {
29 | setError('Invalid email or password');
30 | }
31 | } catch (err: any) {
32 | if (err.code === 'auth/user-not-found') {
33 | setError('No account found with this email');
34 | } else if (err.code === 'auth/wrong-password') {
35 | setError('Incorrect password');
36 | } else if (err.code === 'auth/invalid-email') {
37 | setError('Invalid email address');
38 | } else if (err.code === 'auth/user-disabled') {
39 | setError('This account has been disabled');
40 | } else {
41 | setError('Login failed. Please try again.');
42 | }
43 | } finally {
44 | setLoading(false);
45 | }
46 | };
47 |
48 | return (
49 |
52 |
53 |
54 | Asset Demo
55 |
56 |
57 |
58 | Sign in to continue
59 |
60 |
61 |
72 |
73 | setShowPassword(!showPassword)}
87 | />
88 | }
89 | />
90 |
91 | {error ? (
92 |
93 | {error}
94 |
95 | ) : null}
96 |
97 |
105 |
106 |
107 | );
108 | }
109 |
110 | const styles = StyleSheet.create({
111 | container: {
112 | flex: 1,
113 | },
114 | content: {
115 | flex: 1,
116 | justifyContent: 'center',
117 | padding: 20,
118 | },
119 | title: {
120 | textAlign: 'center',
121 | marginBottom: 8,
122 | fontWeight: 'bold',
123 | },
124 | subtitle: {
125 | textAlign: 'center',
126 | marginBottom: 32,
127 | opacity: 0.6,
128 | },
129 | input: {
130 | marginBottom: 16,
131 | },
132 | button: {
133 | marginTop: 8,
134 | paddingVertical: 6,
135 | },
136 | });
137 |
--------------------------------------------------------------------------------
/context/FirestoreContext.tsx:
--------------------------------------------------------------------------------
1 | import { getApp } from '@react-native-firebase/app';
2 | import {
3 | addDoc,
4 | collection,
5 | deleteDoc,
6 | doc,
7 | getDoc,
8 | getFirestore,
9 | onSnapshot,
10 | updateDoc,
11 | } from '@react-native-firebase/firestore';
12 | import { createContext, useContext, useEffect, useState } from 'react';
13 |
14 | import { useAuth } from '@/context/AuthContext';
15 | import { Asset } from '@/types/Asset';
16 |
17 | interface FirestoreContextType {
18 | assets: Asset[];
19 | loading: boolean;
20 | createAsset: (assetData: Omit) => Promise;
21 | updateAsset: (id: string, assetData: Partial) => Promise;
22 | deleteAsset: (id: string) => Promise;
23 | getAsset: (id: string) => Promise;
24 | subscribeToAsset: (id: string, callback: (asset: Asset | null) => void) => () => void;
25 | }
26 |
27 | export const FirestoreContext = createContext(undefined);
28 |
29 | interface FirestoreProviderProps {
30 | children: React.ReactNode;
31 | }
32 |
33 | export function FirestoreProvider({ children }: FirestoreProviderProps) {
34 | const [assets, setAssets] = useState([]);
35 | const [loading, setLoading] = useState(true);
36 | const { currentUser } = useAuth();
37 |
38 | const app = getApp();
39 | const firestore = getFirestore(app);
40 |
41 | useEffect(() => {
42 | if (!currentUser) {
43 | setLoading(false);
44 | setAssets([]);
45 | return;
46 | }
47 |
48 | const assetsCollection = collection(firestore, `users/${currentUser.uid}/assets`);
49 |
50 | const unsubscribe = onSnapshot(
51 | assetsCollection,
52 | (snapshot) => {
53 | const assetsData = snapshot.docs.map((doc) => ({
54 | id: doc.id,
55 | ...doc.data(),
56 | })) as Asset[];
57 |
58 | setAssets(assetsData);
59 | setLoading(false);
60 | },
61 | (error) => {
62 | console.error('Error fetching assets:', error);
63 | setLoading(false);
64 | }
65 | );
66 |
67 | return () => unsubscribe();
68 | }, [currentUser]);
69 |
70 | const createAsset = async (assetData: Omit): Promise => {
71 | if (!currentUser) {
72 | throw new Error('You must be logged in to create assets.');
73 | }
74 |
75 | const assetsCollection = collection(firestore, `users/${currentUser.uid}/assets`);
76 |
77 | const docRef = await addDoc(assetsCollection, {
78 | ...assetData,
79 | createdAt: new Date(),
80 | });
81 |
82 | return docRef.id;
83 | };
84 |
85 | const updateAsset = async (id: string, assetData: Partial): Promise => {
86 | if (!currentUser) {
87 | throw new Error('You must be logged in to update assets.');
88 | }
89 |
90 | const docRef = doc(firestore, `users/${currentUser.uid}/assets`, id);
91 |
92 | await updateDoc(docRef, {
93 | ...assetData,
94 | updatedAt: new Date(),
95 | });
96 | };
97 |
98 | const deleteAsset = async (id: string): Promise => {
99 | if (!currentUser) {
100 | throw new Error('You must be logged in to delete assets.');
101 | }
102 |
103 | const docRef = doc(firestore, `users/${currentUser.uid}/assets`, id);
104 |
105 | await deleteDoc(docRef);
106 | };
107 |
108 | const getAsset = async (id: string): Promise => {
109 | if (!currentUser) {
110 | throw new Error('You must be logged in to fetch assets.');
111 | }
112 |
113 | const docRef = doc(firestore, `users/${currentUser.uid}/assets`, id);
114 | const docSnap = await getDoc(docRef);
115 |
116 | if (docSnap.exists()) {
117 | return { id: docSnap.id, ...docSnap.data() } as Asset;
118 | }
119 |
120 | return null;
121 | };
122 |
123 | const subscribeToAsset = (id: string, callback: (asset: Asset | null) => void): (() => void) => {
124 | if (!currentUser) {
125 | callback(null);
126 | return () => {};
127 | }
128 |
129 | const docRef = doc(firestore, `users/${currentUser.uid}/assets`, id);
130 |
131 | const unsubscribe = onSnapshot(
132 | docRef,
133 | (docSnap) => {
134 | if (docSnap.exists()) {
135 | callback({ id: docSnap.id, ...docSnap.data() } as Asset);
136 | } else {
137 | callback(null);
138 | }
139 | },
140 | (error) => {
141 | console.error('Error subscribing to asset:', error);
142 | callback(null);
143 | }
144 | );
145 |
146 | return unsubscribe;
147 | };
148 |
149 | const value: FirestoreContextType = {
150 | assets,
151 | loading,
152 | createAsset,
153 | updateAsset,
154 | deleteAsset,
155 | getAsset,
156 | subscribeToAsset,
157 | };
158 |
159 | return {children};
160 | }
161 |
162 | export function useFirestore() {
163 | const context = useContext(FirestoreContext);
164 | if (context === undefined) {
165 | throw new Error('useFirestore must be used within a FirestoreProvider');
166 | }
167 | return context;
168 | }
169 |
--------------------------------------------------------------------------------
/app/(tabs)/(my-assets)/details/[id].tsx:
--------------------------------------------------------------------------------
1 | import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
2 | import { useEffect, useState } from 'react';
3 | import { FlatList, Image, ScrollView, StyleSheet } from 'react-native';
4 | import { Divider, IconButton, List } from 'react-native-paper';
5 |
6 | import { EmptyState } from '@/components/EmptyState';
7 | import { LoadingState } from '@/components/LoadingState';
8 | import { View } from '@/components/Themed';
9 | import { useFirestore } from '@/context/FirestoreContext';
10 | import { Asset } from '@/types/Asset';
11 |
12 | export default function AssetDetailsScreen() {
13 | const { id } = useLocalSearchParams();
14 | const [asset, setAsset] = useState(null);
15 | const [loading, setLoading] = useState(true);
16 | const { subscribeToAsset } = useFirestore();
17 | const router = useRouter();
18 |
19 | useEffect(() => {
20 | if (!id) {
21 | setLoading(false);
22 | return;
23 | }
24 |
25 | const unsubscribe = subscribeToAsset(id as string, (updatedAsset) => {
26 | setAsset(updatedAsset);
27 | setLoading(false);
28 | });
29 |
30 | return unsubscribe;
31 | }, [id, subscribeToAsset]);
32 |
33 | if (loading) {
34 | return (
35 | <>
36 |
37 |
38 | >
39 | );
40 | }
41 |
42 | if (!asset) {
43 | return ;
44 | }
45 |
46 | return (
47 |
48 | (
51 | {
56 | router.push(`/(tabs)/(my-assets)/edit/${asset.id}`);
57 | }}
58 | />
59 | ),
60 | }}
61 | />
62 |
63 |
64 | Asset Information
65 |
66 | }
70 | />
71 |
72 |
73 | {asset.description && (
74 | <>
75 | }
80 | />
81 |
82 | >
83 | )}
84 |
85 | {asset.location && (
86 | <>
87 | }
91 | />
92 |
93 | >
94 | )}
95 |
96 | {asset.createdAt && (
97 | <>
98 | }
106 | />
107 |
108 | >
109 | )}
110 |
111 |
112 | {asset.attachments && asset.attachments.length > 0 && (
113 |
114 | Attachments ({asset.attachments.length})
115 |
116 | index.toString()}
119 | numColumns={3}
120 | columnWrapperStyle={styles.thumbnailRow}
121 | renderItem={({ item: uri }) => (
122 |
123 |
124 |
125 | )}
126 | showsVerticalScrollIndicator={false}
127 | scrollEnabled={false}
128 | ItemSeparatorComponent={() => }
129 | />
130 |
131 |
132 | )}
133 |
134 |
135 | }
140 | />
141 |
142 |
143 |
144 | );
145 | }
146 |
147 | const styles = StyleSheet.create({
148 | container: {
149 | flex: 1,
150 | },
151 | loader: {
152 | marginTop: 50,
153 | },
154 | scrollView: {
155 | flex: 1,
156 | },
157 | idText: {
158 | fontFamily: 'monospace',
159 | fontSize: 12,
160 | },
161 | thumbnailContainer: {
162 | padding: 8,
163 | },
164 | thumbnailRow: {
165 | justifyContent: 'flex-start',
166 | paddingHorizontal: 8,
167 | gap: 10,
168 | },
169 | thumbnailWrapper: {
170 | width: 100,
171 | height: 100,
172 | borderRadius: 8,
173 | overflow: 'hidden',
174 | },
175 | rowSeparator: {
176 | height: 12,
177 | },
178 | thumbnail: {
179 | width: '100%',
180 | height: '100%',
181 | },
182 | });
183 |
--------------------------------------------------------------------------------
/components/AssetForm/AssetForm.tsx:
--------------------------------------------------------------------------------
1 | import * as ImagePicker from 'expo-image-picker';
2 | import { useRouter } from 'expo-router';
3 | import { useEffect, useState } from 'react';
4 | import { Controller, useForm } from 'react-hook-form';
5 | import {
6 | ActionSheetIOS,
7 | Alert,
8 | FlatList,
9 | Image,
10 | Platform,
11 | ScrollView,
12 | StyleSheet,
13 | } from 'react-native';
14 | import { Button, Divider, HelperText, IconButton, Text, TextInput } from 'react-native-paper';
15 |
16 | import { View } from '@/components/Themed';
17 | import { useFirestore } from '@/context/FirestoreContext';
18 | import { Asset } from '@/types/Asset';
19 |
20 | interface AssetFormProps {
21 | assetId?: string;
22 | initialValues?: Asset;
23 | initialAttachments?: string[];
24 | }
25 |
26 | export default function AssetForm({ assetId, initialValues, initialAttachments }: AssetFormProps) {
27 | const {
28 | control,
29 | handleSubmit: handleFormSubmit,
30 | formState: { errors, isDirty },
31 | reset,
32 | watch,
33 | } = useForm({
34 | defaultValues: initialValues || {
35 | name: '',
36 | description: '',
37 | location: '',
38 | },
39 | });
40 | const [attachments, setAttachments] = useState(initialAttachments || []);
41 | const [saving, setSaving] = useState(false);
42 | const router = useRouter();
43 | const { createAsset, updateAsset } = useFirestore();
44 |
45 | const isAttachmentsDirty =
46 | JSON.stringify(attachments) !== JSON.stringify(initialAttachments || []);
47 | const isFormDirty = isDirty || isAttachmentsDirty;
48 |
49 | useEffect(() => {
50 | if (initialValues) {
51 | reset(initialValues);
52 | }
53 | if (initialAttachments) {
54 | setAttachments(initialAttachments);
55 | }
56 | }, [initialValues, initialAttachments, reset]);
57 |
58 | const handleChooseAttachment = () => {
59 | if (Platform.OS === 'ios') {
60 | ActionSheetIOS.showActionSheetWithOptions(
61 | {
62 | options: ['Cancel', 'Take Photo', 'Choose from Library'],
63 | cancelButtonIndex: 0,
64 | },
65 | (buttonIndex) => {
66 | if (buttonIndex === 1) {
67 | openCamera();
68 | } else if (buttonIndex === 2) {
69 | openPhotoLibrary();
70 | }
71 | }
72 | );
73 | } else {
74 | Alert.alert(
75 | 'Choose Attachment',
76 | 'Select an option',
77 | [
78 | { text: 'Cancel', style: 'cancel' },
79 | { text: 'Take Photo', onPress: openCamera },
80 | { text: 'Choose from Library', onPress: openPhotoLibrary },
81 | ],
82 | { cancelable: true }
83 | );
84 | }
85 | };
86 |
87 | const openCamera = async () => {
88 | const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
89 |
90 | if (!permissionResult.granted) {
91 | Alert.alert('Permission Required', 'Camera permission is required to take photos.');
92 | return;
93 | }
94 |
95 | const result = await ImagePicker.launchCameraAsync({
96 | mediaTypes: ['images'],
97 | allowsEditing: true,
98 | aspect: [4, 3],
99 | quality: 1,
100 | });
101 |
102 | if (!result.canceled) {
103 | setAttachments([...attachments, result.assets[0].uri]);
104 | }
105 | };
106 |
107 | const openPhotoLibrary = async () => {
108 | const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
109 |
110 | if (!permissionResult.granted) {
111 | Alert.alert('Permission Required', 'Photo library permission is required to select photos.');
112 | return;
113 | }
114 |
115 | const result = await ImagePicker.launchImageLibraryAsync({
116 | mediaTypes: ['images'],
117 | allowsEditing: false,
118 | allowsMultipleSelection: true,
119 | quality: 1,
120 | });
121 |
122 | if (!result.canceled) {
123 | const newUris = result.assets.map((asset) => asset.uri);
124 | setAttachments([...attachments, ...newUris]);
125 | }
126 | };
127 |
128 | const handleSubmit = async (data: Asset) => {
129 | setSaving(true);
130 | try {
131 | const assetData = {
132 | name: data.name.trim(),
133 | description: data.description.trim(),
134 | location: data.location?.trim(),
135 | attachments: attachments,
136 | };
137 |
138 | if (assetId) {
139 | await updateAsset(assetId, assetData);
140 |
141 | Alert.alert('Success', 'Asset updated successfully!', [
142 | {
143 | text: 'OK',
144 | onPress: () => {
145 | router.back();
146 | },
147 | },
148 | ]);
149 | } else {
150 | const newAssetId = await createAsset(assetData);
151 |
152 | Alert.alert('Success', 'Asset saved successfully!', [
153 | {
154 | text: 'OK',
155 | onPress: () => {
156 | reset();
157 | setAttachments([]);
158 | router.push(`/(tabs)/(my-assets)/details/${newAssetId}`);
159 | },
160 | },
161 | ]);
162 | }
163 | } catch (error) {
164 | console.error('Error saving asset:', error);
165 | Alert.alert('Error', 'Failed to save asset. Please try again.');
166 | } finally {
167 | setSaving(false);
168 | }
169 | };
170 |
171 | return (
172 |
173 |
174 |
175 | (
180 | <>
181 |
189 | {errors.name && (
190 |
191 | {errors.name.message}
192 |
193 | )}
194 | >
195 | )}
196 | />
197 |
198 |
199 |
200 | (
205 | <>
206 |
216 | {errors.description && (
217 |
218 | {errors.description.message}
219 |
220 | )}
221 | >
222 | )}
223 | />
224 |
225 |
226 |
227 | (
231 | <>
232 |
240 | {errors.location && (
241 |
242 | {errors.location.message}
243 |
244 | )}
245 | >
246 | )}
247 | />
248 |
249 |
250 |
251 |
252 | Attachments {attachments.length > 0 && `(${attachments.length})`}
253 |
254 | {attachments.length > 0 && (
255 | index.toString()}
260 | renderItem={({ item, index }) => (
261 |
262 |
263 | {
268 | setAttachments(attachments.filter((_, i) => i !== index));
269 | }}
270 | />
271 |
272 | )}
273 | style={styles.thumbnailList}
274 | />
275 | )}
276 |
283 |
284 |
285 |
286 |
287 |
288 |
296 |
297 |
298 | );
299 | }
300 |
301 | const styles = StyleSheet.create({
302 | wrapper: {
303 | flex: 1,
304 | },
305 | scrollView: {
306 | flex: 1,
307 | padding: 16,
308 | },
309 | scrollContent: {
310 | paddingBottom: 16,
311 | justifyContent: 'space-between',
312 | gap: 12,
313 | },
314 | fieldGroup: {
315 | marginBottom: 16,
316 | },
317 | attachmentLabel: {
318 | marginBottom: 8,
319 | opacity: 0.7,
320 | },
321 | thumbnailList: {
322 | marginBottom: 8,
323 | },
324 | thumbnailContainer: {
325 | position: 'relative',
326 | marginRight: 8,
327 | },
328 | thumbnail: {
329 | width: 80,
330 | height: 80,
331 | borderRadius: 8,
332 | backgroundColor: '#f0f0f0',
333 | },
334 | removeButton: {
335 | position: 'absolute',
336 | top: -8,
337 | right: -8,
338 | margin: 0,
339 | backgroundColor: 'white',
340 | },
341 | button: {
342 | marginTop: 8,
343 | paddingVertical: 6,
344 | },
345 | footer: {
346 | padding: 16,
347 | },
348 | saveButton: {
349 | paddingVertical: 6,
350 | },
351 | });
352 |
--------------------------------------------------------------------------------