Warehouse Organizer with On-Device AI

Build an offline-first warehouse app with on-device AI vision for product placement.

Introduction

30 Second Summary

Warehouse workers figure out where products fit on shelves every day. Getting dimensions wrong means wasted space, misplaced inventory, and hours of reorganization.

In this project, you will build a mobile app that uses on-device AI to estimate product dimensions from photos and recommend the best shelf placement. The entire AI pipeline runs offline on the phone itself.

What You'll Build

A warehouse management Android app that uses Gemma 4 E2B for on-device vision-based measurements, WatermelonDB for offline-first storage, and Supabase for cloud sync.

By the end of this project, you'll have:

  • An on-device AI pipeline using Gemma 4 E2B that estimates product dimensions from photos with zero internet dependency.
  • An offline-first relational database with a Warehouse > Zones > Shelves > Slots > Products hierarchy that syncs to the cloud when connectivity returns.
  • AI-powered placement recommendations that match products to the best available shelf slot based on dimensions and clearance rules.
  • Secret Mission: Add a barcode/QR scanner with auto-fill and multi-worker conflict resolution with manual override.

Are there any prerequisites?

This project assumes intermediate React Native and JavaScript experience, including familiarity with async/await, React hooks, and basic SQL concepts. You also need a physical Android device for AI testing since emulators do not support the AI Edge SDKs.

Set Up Your Development Environment

To build a warehouse app that runs AI entirely on-device, you need a specific development stack. The app uses React Native with Expo for the mobile framework, and a physical Android device for on-device AI inference.

In this step, you will install all the tools, create your Expo project, and configure it so that WatermelonDB decorators and native libraries work correctly on a real device.

In this step, get ready to:
  • Install Node.js, VS Code, Expo CLI, and Android Studio with a connected physical device.
  • Create your Expo project and install all required dependencies.
  • Configure WatermelonDB build properties and verify the app runs on your device.
Install Node.js, VS Code, Android Studio, and Expo CLI

This project requires four core tools working together. Node.js runs JavaScript outside the browser, VS Code is your code editor, Android Studio provides the Android SDK and device tools, and Expo CLI manages your React Native project.

  • Check if Node.js is already installed by running this command in your terminal:
node --version

✔️ I see a version number

Node.js is ready. Move on to the next tool.

ⓧ Command not found

  • Download and install Node.js LTS from the official website:
  • https://nodejs.org
  • Run the installer and accept the defaults.
  • Close and reopen your terminal, then run node --version again to confirm it works.
  • Install VS Code if you do not already have it. Download it from https://code.visualstudio.com and run the installer.
  • Install Android Studio from https://developer.android.com/studio. During installation, make sure the Android SDK (API 26 or higher) is selected.
  • Connect your physical Android device to your computer via USB.
  • On your Android device, enable Developer Options and turn on USB Debugging.

Why a physical device?

The react-native-ai-core library requires a physical Android device. Android emulators do not support the AI Edge SDKs needed for on-device inference.

  • Verify your device is connected by running:
adb devices

You should see your device listed with the status device next to its serial number.

Don't see your device listed?

  • Make sure USB Debugging is enabled in your phone's Developer Options.
  • Try a different USB cable. Some cables only carry power, not data.
  • Check for a confirmation dialog on your phone asking you to allow USB debugging from this computer.

Help me connect my Android device for development

Create the Expo project and install dependencies

With your tools in place, you can scaffold the React Native project. Expo's default template includes TypeScript configured out of the box.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Expo project by running this command:
npx create-expo-app@latest warehouse-organizer --template default@sdk-55

What does this command do?

  • npx create-expo-app@latest downloads and runs the latest Expo project scaffolding tool.
  • warehouse-organizer is the name of your project folder.
  • --template default@sdk-55 uses the SDK 55 default template with TypeScript and Expo Router pre-configured.
  • The tool will prompt you to confirm the project name. Press Enter to accept.
  • Move into the project folder by running:
cd warehouse-organizer
  • Install the Expo-managed packages by running:
npx expo install expo-camera expo-file-system expo-secure-store @react-native-async-storage/async-storage expo-build-properties
  • Install the remaining dependencies by running:
npm install react-native-ai-core @nozbe/watermelondb @supabase/supabase-js

Why two install commands?

npx expo install ensures Expo-managed packages match your SDK version. Third-party libraries like react-native-ai-core and @nozbe/watermelondb use standard npm install because they manage their own version compatibility.

  • Confirm the packages exist by running:
ls node_modules | grep -E "watermelondb|ai-core|supabase"

You should see folder names matching each package in the output.

Seeing install errors?

  • If you see peer dependency warnings, these are usually safe to ignore for now.
  • If npm install fails with a network error, check your internet connection and try again.
  • Make sure you are inside the warehouse-organizer folder before running the install commands.

Help me fix dependency install errors in my Expo project

Configure WatermelonDB and generate the dev build

WatermelonDB uses decorators in its model classes, which requires a Babel plugin. It also needs specific native build settings for both iOS and Android to handle shared C++ libraries.

  • Install the Babel decorator plugin by running:
npm install --save-dev @babel/plugin-proposal-decorators
  • Open babel.config.js in VS Code and replace its contents with:
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      // Required for WatermelonDB model decorators
      ['@babel/plugin-proposal-decorators', { legacy: true }],
    ],
  };
};

What does this config do?

  • babel-preset-expo is the standard Expo preset that handles JSX, TypeScript, and React Native transforms.
  • @babel/plugin-proposal-decorators with legacy: true enables the @field, @relation, and @lazy decorators that WatermelonDB models use.
  • Save babel.config.js.
  • Open app.json and add the expo-build-properties plugin to the plugins array. Replace the file contents with:
{
  "expo": {
    "name": "warehouse-organizer",
    "slug": "warehouse-organizer",
    "version": "1.0.0",
    "orientation": "portrait",
    "scheme": "warehouse-organizer",
    "platforms": ["android"],
    "plugins": [
      [
        "expo-build-properties",
        {
          "ios": {
            "extraPods": [
              {
                "name": "simdjson",
                "configurations": ["Debug", "Release"],
                "path": "../node_modules/@nozbe/simdjson",
                "modular_headers": true
              }
            ]
          },
          "android": {
            "packagingOptions": {
              "pickFirst": ["**/libc++_shared.so"]
            },
            "minSdkVersion": 26
          }
        }
      ]
    ]
  }
}

What do these build properties do?

The iOS extraPods section adds the simdjson pod that WatermelonDB uses for fast JSON parsing on iOS.

The Android pickFirst rule resolves a conflict where multiple native libraries try to include libc++_shared.so. The minSdkVersion of 26 is required by react-native-ai-core.

  • Save app.json.
  • Generate the native Android and iOS directories by running:
npx expo prebuild

What does prebuild do?

npx expo prebuild generates the native android/ and ios/ directories from your app.json config. This converts the managed Expo project into a custom dev build that can include native dependencies like WatermelonDB and react-native-ai-core.

  • The prebuild command will ask which platforms to generate. Select android (and optionally iOS if you have a Mac).
  • Run the app on your connected device by running:
npx expo run:android

The first build takes several minutes. When it finishes, you should see the default Expo app running on your physical Android device.

Build failing?

  • Make sure your device is still connected. Run adb devices to confirm.
  • Check that Android Studio's SDK is properly configured. Open Android Studio, go to Settings then SDK Manager, and confirm API 26+ is installed.
  • If you see a libc++_shared.so conflict error, double-check that your app.json includes the pickFirst setting exactly as shown above.

Help me fix my Expo Android build errors

✔️ Awesome, I've got everything!

Great. Double check that you have saved both app.json and babel.config.js.

ⓧ I'd like to double check the full code

Here is babel.config.js:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      // Required for WatermelonDB model decorators
      ['@babel/plugin-proposal-decorators', { legacy: true }],
    ],
  };
};

Here is app.json:

{
  "expo": {
    "name": "warehouse-organizer",
    "slug": "warehouse-organizer",
    "version": "1.0.0",
    "orientation": "portrait",
    "scheme": "warehouse-organizer",
    "platforms": ["android"],
    "plugins": [
      [
        "expo-build-properties",
        {
          "ios": {
            "extraPods": [
              {
                "name": "simdjson",
                "configurations": ["Debug", "Release"],
                "path": "../node_modules/@nozbe/simdjson",
                "modular_headers": true
              }
            ]
          },
          "android": {
            "packagingOptions": {
              "pickFirst": ["**/libc++_shared.so"]
            },
            "minSdkVersion": 26
          }
        }
      ]
    ]
  }
}

Your environment is ready and the app is running on real hardware. Next up, you will set up the Supabase backend that powers cloud sync and multi-worker authentication.

Set Up Your Supabase Backend

Your app needs a cloud database to store warehouse data and sync across multiple worker devices. Supabase provides a hosted Postgres database with built-in authentication, storage, and auto-generated APIs.

In this step, you will create the full database schema that models the warehouse hierarchy (Warehouse > Zones > Shelves > Slots > Products), lock it down with Row Level Security, and wire up the Supabase client in your Expo app so it can talk to the backend.

In this step, get ready to:
  • Create your Supabase project and database schema with relational tables.
  • Enable Row Level Security and define access policies.
  • Configure the Supabase client in your Expo app with secure session persistence.
Create your Supabase project and database schema

The warehouse app needs tables that mirror the physical layout: warehouses contain zones, zones contain shelves, shelves contain slots, and products live in slots.

  • Open your browser and go to https://database.new to create a new Supabase project.
  • Give your project a name (e.g. warehouse-organizer), choose a strong database password, and select a region close to you.
  • Wait for the project to finish provisioning (this takes about a minute).

Once your project is ready, you will use the SQL Editor to create all the tables at once.

  • In the Supabase Dashboard, click SQL Editor in the left sidebar.
  • Paste the following SQL and click Run to create the warehouse schema:
-- Warehouses table
create table warehouses (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  address text,
  created_at timestamptz default now()
);

-- Zones within a warehouse
create table zones (
  id uuid primary key default gen_random_uuid(),
  warehouse_id uuid references warehouses(id) on delete cascade not null,
  name text not null,
  created_at timestamptz default now()
);

-- Shelves within a zone
create table shelves (
  id uuid primary key default gen_random_uuid(),
  zone_id uuid references zones(id) on delete cascade not null,
  name text not null,
  created_at timestamptz default now()
);

-- Slots within a shelf
create table slots (
  id uuid primary key default gen_random_uuid(),
  shelf_id uuid references shelves(id) on delete cascade not null,
  width_cm numeric not null,
  height_cm numeric not null,
  depth_cm numeric not null,
  is_occupied boolean default false,
  server_id text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Products placed in slots
create table products (
  id uuid primary key default gen_random_uuid(),
  slot_id uuid references slots(id) on delete set null,
  name text not null,
  width_cm numeric not null,
  height_cm numeric not null,
  depth_cm numeric not null,
  photo_local_path text,
  photo_remote_url text,
  placed_by uuid references auth.users,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Activity log for tracking placements
create table activity_log (
  id uuid primary key default gen_random_uuid(),
  worker_id uuid references auth.users not null,
  product_id uuid references products(id) on delete cascade not null,
  slot_id uuid references slots(id) on delete cascade not null,
  action text not null,
  timestamp timestamptz default now()
);

What does this SQL do?

This creates six tables that model the physical warehouse hierarchy. Each table uses uuid primary keys (auto-generated) and timestamptz for timestamps.

Foreign keys with on delete cascade mean that deleting a warehouse automatically removes its zones, shelves, and slots. The products table references auth.users so each product is linked to the worker who placed it.

  • After running the SQL, click Table Editor in the left sidebar.
  • Verify you see all six tables: warehouses, zones, shelves, slots, products, and activity_log.

Don't see all six tables?

Make sure you ran the entire SQL block in one go. If only some tables were created, you may have a syntax error partway through. Check the error output at the bottom of the SQL Editor for the specific line number.

help me debug why my Supabase SQL didn't create all six tables

Enable Row Level Security and define policies

Row Level Security (RLS) ensures that even if someone has your API key, they can only access data the policies allow. Without RLS, any authenticated user could read or modify any row in your tables.

  • Go back to the SQL Editor in the Supabase Dashboard.
  • Paste and run the following SQL to enable RLS and create access policies:
-- Enable RLS on all tables
alter table warehouses enable row level security;
alter table zones enable row level security;
alter table shelves enable row level security;
alter table slots enable row level security;
alter table products enable row level security;
alter table activity_log enable row level security;

-- All authenticated workers can read all warehouse data
create policy "Authenticated users can read warehouses"
  on warehouses for select to authenticated using (true);

create policy "Authenticated users can read zones"
  on zones for select to authenticated using (true);

create policy "Authenticated users can read shelves"
  on shelves for select to authenticated using (true);

create policy "Authenticated users can read slots"
  on slots for select to authenticated using (true);

create policy "Authenticated users can read products"
  on products for select to authenticated using (true);

create policy "Authenticated users can read activity_log"
  on activity_log for select to authenticated using (true);

-- Workers can insert their own products
create policy "Users can insert their own products"
  on products for insert to authenticated
  with check ((select auth.uid()) = placed_by);

-- Workers can update their own products
create policy "Users can update their own products"
  on products for update to authenticated
  using ((select auth.uid()) = placed_by)
  with check ((select auth.uid()) = placed_by);

-- Workers can insert their own activity log entries
create policy "Users can insert their own activity"
  on activity_log for insert to authenticated
  with check ((select auth.uid()) = worker_id);

-- Workers can update their own activity log entries
create policy "Users can update their own activity"
  on activity_log for update to authenticated
  using ((select auth.uid()) = worker_id)
  with check ((select auth.uid()) = worker_id);

-- Authenticated users can insert and update slots (needed for placement)
create policy "Authenticated users can insert slots"
  on slots for insert to authenticated with check (true);

create policy "Authenticated users can update slots"
  on slots for update to authenticated using (true) with check (true);

What do these policies do?

The SELECT policies let any authenticated worker read all warehouse data. This is intentional because every worker needs to see the full shelf map and product locations.

The INSERT and UPDATE policies on products and activity_log use auth.uid() to ensure workers can only create or edit their own entries. This prevents one worker from overwriting another's placements.

  • After running the SQL, click Authentication in the left sidebar, then Policies.
  • Verify that each table shows a green "RLS enabled" badge and the policies you just created are listed.

Seeing 'permission denied' errors?

If you get permission errors when testing later, make sure RLS is enabled on the exact table you're querying. You can verify by running SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; in the SQL Editor.

help me fix RLS permission denied errors in Supabase

Now create a storage bucket for product and shelf photos.

  • In the SQL Editor, run the following to create the storage bucket:
-- Create a storage bucket for warehouse photos
insert into storage.buckets (id, name, public)
  values ('warehouse-photos', 'warehouse-photos', false);

Why a private bucket?

Setting public to false means photos require authentication to access. This protects sensitive warehouse imagery from public access. Your app will use authenticated Supabase client calls to upload and retrieve photos.

Configure the Supabase client in your Expo app

Your mobile app needs a way to communicate with the Supabase backend. You will create an environment file with your project credentials, then a TypeScript module that initializes the Supabase client with secure session storage.

  • In the Supabase Dashboard, click Project Settings (the gear icon) in the left sidebar.
  • Click API under the Configuration section.
  • Copy the Project URL and the anon/public key (or the publishable key if you see the new format). You will use these in your app.

What is the anon key?

The anon key is safe to include in your mobile app. It only grants access to data that your RLS policies explicitly allow. Supabase is transitioning to new publishable keys (sb_publishable_xxx), but the legacy anon key works until end of 2026.

  • In VS Code, create a .env file in the root of your Expo project with your Supabase credentials:
EXPO_PUBLIC_SUPABASE_URL=your-supabase-project-url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key

Why the EXPO_PUBLIC_ prefix?

Expo requires environment variables to be prefixed with EXPO_PUBLIC_ to be accessible in your app code. Variables without this prefix are not bundled into the client.

  • Create a new folder called src/lib in your project.
  • Inside src/lib, create a file called supabase.ts with the Supabase client configuration:
import 'react-native-url-polyfill/auto'
import { createClient } from '@supabase/supabase-js'
import * as SecureStore from 'expo-secure-store'

// Custom storage adapter using expo-secure-store
// Provides encrypted session persistence on-device
const ExpoSecureStoreAdapter = {
  getItem: (key: string) => {
    return SecureStore.getItemAsync(key)
  },
  setItem: (key: string, value: string) => {
    SecureStore.setItemAsync(key, value)
  },
  removeItem: (key: string) => {
    SecureStore.deleteItemAsync(key)
  },
}

// Load environment variables
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!

// Initialize the Supabase client with secure session storage
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: ExpoSecureStoreAdapter,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
})

What does this code do?

The ExpoSecureStoreAdapter wraps expo-secure-store to match the storage interface that Supabase expects (getItem, setItem, removeItem). This stores the user's auth session in the device's secure enclave rather than plain AsyncStorage.

Setting detectSessionInUrl to false disables browser-style URL session detection, which does not apply to mobile apps. The autoRefreshToken flag ensures expired tokens are automatically refreshed in the background.

  • Save src/lib/supabase.ts and .env.
  • Verify the file structure by running:
ls src/lib/

You should see supabase.ts in the output.

Getting a 'Cannot find module' error?

Make sure @supabase/supabase-js and expo-secure-store are both installed. Check your package.json for both entries. If either is missing, install it with npx expo install.

help me fix module import errors in my Supabase client file

✔️ Awesome, I've got everything!

Great. Double check you have saved both .env and src/lib/supabase.ts.

ⓧ I'd like to double check the full code

Here is the complete src/lib/supabase.ts file:

import 'react-native-url-polyfill/auto'
import { createClient } from '@supabase/supabase-js'
import * as SecureStore from 'expo-secure-store'

// Custom storage adapter using expo-secure-store
// Provides encrypted session persistence on-device
const ExpoSecureStoreAdapter = {
  getItem: (key: string) => {
    return SecureStore.getItemAsync(key)
  },
  setItem: (key: string, value: string) => {
    SecureStore.setItemAsync(key, value)
  },
  removeItem: (key: string) => {
    SecureStore.deleteItemAsync(key)
  },
}

// Load environment variables
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!

// Initialize the Supabase client with secure session storage
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: ExpoSecureStoreAdapter,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
})

And your .env file should look like this (with your actual values):

EXPO_PUBLIC_SUPABASE_URL=your-supabase-project-url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key

Your backend is fully configured. Next up, you will build the authentication flow so workers can log in and their sessions persist across app restarts.

Implement Authentication

Your Supabase backend is ready with tables, RLS policies, and a storage bucket. Now you need a way for warehouse workers to identify themselves before they can read or write any data.

In this step, you will build a full authentication flow using Supabase Auth. Workers will sign up, log in, and stay logged in across app restarts. An AuthProvider context will gate the entire app so unauthenticated users only see the login screen.

In this step, get ready to:
  • Build login and registration screens that call Supabase Auth.
  • Create an AuthProvider context that persists sessions and gates navigation.
  • Add a profile screen showing the authenticated worker's name and ID.
Build the login and registration screens

Every worker needs a way to sign in. You will create two screens: one for existing workers to log in, and one for new workers to register.

  • Create the file src/screens/LoginScreen.tsx inside your project.
  • Add the following code to build the login screen:
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text, StyleSheet, Alert } from 'react-native';
import { supabase } from '../lib/supabase';

export default function LoginScreen({ onNavigateRegister }: { onNavigateRegister: () => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  // Handle login with Supabase Auth
  const handleLogin = async () => {
    setLoading(true);
    const { error } = await supabase.auth.signInWithPassword({ email, password });
    if (error) {
      Alert.alert('Login Error', error.message);
    }
    setLoading(false);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Warehouse Organizer</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
        <Text style={styles.buttonText}>{loading ? 'Signing in...' : 'Sign In'}</Text>
      </TouchableOpacity>
      <TouchableOpacity onPress={onNavigateRegister}>
        <Text style={styles.link}>Don't have an account? Register</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 20 },
  title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, marginBottom: 12 },
  button: { backgroundColor: '#2563eb', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 8 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
  link: { color: '#2563eb', textAlign: 'center', marginTop: 16 },
});

What does this code do?

This screen has two controlled TextInput fields for email and password. When the worker presses Sign In, it calls supabase.auth.signInWithPassword() with those credentials.

If authentication fails, the error message from Supabase displays in an alert. The onNavigateRegister prop lets the login screen link to the registration screen.

  • Create the file src/screens/RegisterScreen.tsx.
  • Add the following code to build the registration screen:
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text, StyleSheet, Alert } from 'react-native';
import { supabase } from '../lib/supabase';

export default function RegisterScreen({ onNavigateLogin }: { onNavigateLogin: () => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [displayName, setDisplayName] = useState('');
  const [loading, setLoading] = useState(false);

  // Handle registration with Supabase Auth
  const handleRegister = async () => {
    setLoading(true);
    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: { data: { display_name: displayName } },
    });
    if (error) {
      Alert.alert('Registration Error', error.message);
    } else {
      Alert.alert('Success', 'Check your email for a confirmation link.');
    }
    setLoading(false);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create Account</Text>
      <TextInput
        style={styles.input}
        placeholder="Display Name"
        value={displayName}
        onChangeText={setDisplayName}
      />
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleRegister} disabled={loading}>
        <Text style={styles.buttonText}>{loading ? 'Creating account...' : 'Register'}</Text>
      </TouchableOpacity>
      <TouchableOpacity onPress={onNavigateLogin}>
        <Text style={styles.link}>Already have an account? Sign In</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 20 },
  title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, marginBottom: 12 },
  button: { backgroundColor: '#2563eb', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 8 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
  link: { color: '#2563eb', textAlign: 'center', marginTop: 16 },
});

What does this code do?

The registration screen collects a display name alongside email and password. The display name is passed through options: { data: { display_name } } so Supabase stores it in the user's metadata.

After a successful sign-up, Supabase sends a confirmation email. The worker needs to click that link before they can sign in.

  • Save both files.

Seeing import errors for supabase?

Make sure your src/lib/supabase.ts file exports the client correctly. Both screens import from '../lib/supabase', so the relative path must be valid from the screens folder.

help me fix my supabase import path

Create the AuthProvider context

Your Supabase client already stores sessions in expo-secure-store, but the app needs a central place to read that session on startup and share it with every screen. That is what the AuthProvider does.

  • Create the file src/context/AuthProvider.tsx.
  • Add the following code to build the authentication context:
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';

// Define the shape of the auth context
type AuthContextType = {
  session: Session | null;
  user: User | null;
  signOut: () => Promise<void>;
};

const AuthContext = createContext<AuthContextType>({
  session: null,
  user: null,
  signOut: async () => {},
});

// Custom hook to access auth state anywhere in the app
export function useAuth() {
  return useContext(AuthContext);
}

export default function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Restore existing session on app start
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setLoading(false);
    });

    // Listen for auth state changes (login, logout, token refresh)
    const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session);
    });

    return () => subscription.unsubscribe();
  }, []);

  // Sign out and clear session
  const signOut = async () => {
    await supabase.auth.signOut();
  };

  const value = {
    session,
    user: session?.user ?? null,
    signOut,
  };

  // Show nothing while checking for an existing session
  if (loading) {
    return null;
  }

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

What does this code do?

supabase.auth.getSession() checks for a stored session token on app start. Because you configured expo-secure-store as the storage adapter in Step 2, this call pulls the saved session from encrypted device storage.

supabase.auth.onAuthStateChange() fires whenever the auth state changes. This covers sign-in, sign-out, and automatic token refresh events. The subscription is cleaned up when the component unmounts.

The useAuth() hook gives any child component access to the current session, user, and signOut function without passing props down manually.

Why a context instead of prop drilling?

The warehouse app has many screens that need to know who is logged in. Passing session as a prop through every level of nesting would be fragile and verbose. A context provider makes auth state available to any screen in one line via useAuth().

  • Save src/context/AuthProvider.tsx.

TypeScript error on Session or User imports?

Make sure @supabase/supabase-js is installed in your project. The Session and User types are exported directly from that package.

help me fix my Supabase TypeScript imports

Build the profile screen and wire up navigation

The profile screen gives workers a way to see their identity and sign out. It uses the useAuth() hook you just created.

  • Create the file src/screens/ProfileScreen.tsx.
  • Add the following code:
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useAuth } from '../context/AuthProvider';

export default function ProfileScreen() {
  const { user, signOut } = useAuth();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Worker Profile</Text>
      <Text style={styles.label}>Name</Text>
      <Text style={styles.value}>{user?.user_metadata?.display_name || 'Unknown'}</Text>
      <Text style={styles.label}>Worker ID</Text>
      <Text style={styles.value}>{user?.id}</Text>
      <TouchableOpacity style={styles.button} onPress={signOut}>
        <Text style={styles.buttonText}>Sign Out</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, paddingTop: 60 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 30 },
  label: { fontSize: 14, color: '#666', marginTop: 16 },
  value: { fontSize: 18, fontWeight: '500', marginTop: 4 },
  button: { backgroundColor: '#dc2626', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 40 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
});

What does this code do?

The profile screen pulls the current user from useAuth() and displays two pieces of information: the worker's display name (from user_metadata.display_name) and their unique Supabase user ID.

The Sign Out button calls signOut() from the context. This triggers onAuthStateChange in AuthProvider, which sets the session to null and navigates the worker back to the login screen automatically.

Now you need to wire AuthProvider into your app entry point so it controls which screens render.

  • Open your App.tsx file (or app/_layout.tsx if using Expo Router).
  • Wrap your app content with AuthProvider and add conditional navigation:
import React, { useState } from 'react';
import AuthProvider, { useAuth } from './src/context/AuthProvider';
import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen';
import ProfileScreen from './src/screens/ProfileScreen';

// Inner component that reads auth state from context
function AppNavigator() {
  const { session } = useAuth();
  const [showRegister, setShowRegister] = useState(false);

  // If no session, show login or register screens
  if (!session) {
    if (showRegister) {
      return <RegisterScreen onNavigateLogin={() => setShowRegister(false)} />;
    }
    return <LoginScreen onNavigateRegister={() => setShowRegister(true)} />;
  }

  // Authenticated: show main app screens
  return <ProfileScreen />;
}

export default function App() {
  return (
    <AuthProvider>
      <AppNavigator />
    </AuthProvider>
  );
}

What does this code do?

AuthProvider wraps the entire app. Inside, AppNavigator checks if a session exists. Without a session, the worker sees the login or register screen. With a session, they see the main app (currently just the profile screen).

This pattern gates the entire app behind authentication. As you add more screens in later steps, they will all be protected by this same session check.

  • Save App.tsx.
  • Run the app on your physical device.
  • You should see the login screen. Tap the register link, create an account, confirm your email, then sign in.
  • After signing in, you should see the profile screen showing your display name and user ID.

App stuck on a blank screen after login?

Check that onAuthStateChange is updating the session state in AuthProvider. A common mistake is forgetting to destructure the subscription correctly: it must be const { data: { subscription } }, not const { subscription }.

Also confirm your Supabase project has email confirmations configured. If confirmations are enabled, you must click the email link before the session activates.

help me debug my AuthProvider session state

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file you created in this step.

import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text, StyleSheet, Alert } from 'react-native';
import { supabase } from '../lib/supabase';

export default function LoginScreen({ onNavigateRegister }: { onNavigateRegister: () => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  const handleLogin = async () => {
    setLoading(true);
    const { error } = await supabase.auth.signInWithPassword({ email, password });
    if (error) {
      Alert.alert('Login Error', error.message);
    }
    setLoading(false);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Warehouse Organizer</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
        <Text style={styles.buttonText}>{loading ? 'Signing in...' : 'Sign In'}</Text>
      </TouchableOpacity>
      <TouchableOpacity onPress={onNavigateRegister}>
        <Text style={styles.link}>Don't have an account? Register</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 20 },
  title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, marginBottom: 12 },
  button: { backgroundColor: '#2563eb', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 8 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
  link: { color: '#2563eb', textAlign: 'center', marginTop: 16 },
});
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text, StyleSheet, Alert } from 'react-native';
import { supabase } from '../lib/supabase';

export default function RegisterScreen({ onNavigateLogin }: { onNavigateLogin: () => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [displayName, setDisplayName] = useState('');
  const [loading, setLoading] = useState(false);

  const handleRegister = async () => {
    setLoading(true);
    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: { data: { display_name: displayName } },
    });
    if (error) {
      Alert.alert('Registration Error', error.message);
    } else {
      Alert.alert('Success', 'Check your email for a confirmation link.');
    }
    setLoading(false);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create Account</Text>
      <TextInput
        style={styles.input}
        placeholder="Display Name"
        value={displayName}
        onChangeText={setDisplayName}
      />
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleRegister} disabled={loading}>
        <Text style={styles.buttonText}>{loading ? 'Creating account...' : 'Register'}</Text>
      </TouchableOpacity>
      <TouchableOpacity onPress={onNavigateLogin}>
        <Text style={styles.link}>Already have an account? Sign In</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 20 },
  title: { fontSize: 24, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, marginBottom: 12 },
  button: { backgroundColor: '#2563eb', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 8 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
  link: { color: '#2563eb', textAlign: 'center', marginTop: 16 },
});
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';

type AuthContextType = {
  session: Session | null;
  user: User | null;
  signOut: () => Promise<void>;
};

const AuthContext = createContext<AuthContextType>({
  session: null,
  user: null,
  signOut: async () => {},
});

export function useAuth() {
  return useContext(AuthContext);
}

export default function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setLoading(false);
    });

    const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session);
    });

    return () => subscription.unsubscribe();
  }, []);

  const signOut = async () => {
    await supabase.auth.signOut();
  };

  const value = {
    session,
    user: session?.user ?? null,
    signOut,
  };

  if (loading) {
    return null;
  }

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useAuth } from '../context/AuthProvider';

export default function ProfileScreen() {
  const { user, signOut } = useAuth();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Worker Profile</Text>
      <Text style={styles.label}>Name</Text>
      <Text style={styles.value}>{user?.user_metadata?.display_name || 'Unknown'}</Text>
      <Text style={styles.label}>Worker ID</Text>
      <Text style={styles.value}>{user?.id}</Text>
      <TouchableOpacity style={styles.button} onPress={signOut}>
        <Text style={styles.buttonText}>Sign Out</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, paddingTop: 60 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 30 },
  label: { fontSize: 14, color: '#666', marginTop: 16 },
  value: { fontSize: 18, fontWeight: '500', marginTop: 4 },
  button: { backgroundColor: '#dc2626', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 40 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
});
import React, { useState } from 'react';
import AuthProvider, { useAuth } from './src/context/AuthProvider';
import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen';
import ProfileScreen from './src/screens/ProfileScreen';

function AppNavigator() {
  const { session } = useAuth();
  const [showRegister, setShowRegister] = useState(false);

  if (!session) {
    if (showRegister) {
      return <RegisterScreen onNavigateLogin={() => setShowRegister(false)} />;
    }
    return <LoginScreen onNavigateRegister={() => setShowRegister(true)} />;
  }

  return <ProfileScreen />;
}

export default function App() {
  return (
    <AuthProvider>
      <AppNavigator />
    </AuthProvider>
  );
}

Your authentication flow is complete. Workers can register, sign in, stay logged in across restarts, and sign out. Next up, you will define the WatermelonDB schema and models that power the offline-first database.

Define the WatermelonDB Schema and Models

Your authentication layer is working. Workers can sign in, register, and stay logged in across restarts.

Now you need a local database that stores warehouse data directly on the device. This is what makes the app work offline. Every product, shelf, and slot lives in a local SQLite database powered by WatermelonDB. When connectivity returns, the data syncs to Supabase.

In this step, get ready to:
  • Define the WatermelonDB schema with six tables matching your warehouse hierarchy.
  • Create Model classes with field decorators, relations, and lazy queries.
  • Initialize the database and wrap your app in DatabaseProvider.
Define the WatermelonDB schema

WatermelonDB needs a schema that describes every table and column in your local database. Each table maps to one type of entity in your warehouse hierarchy: warehouses, zones, shelves, slots, products, and activity logs.

What are _status and _changed?

WatermelonDB's sync engine uses two special columns on every table. _status tracks whether a record is created, updated, or synced. _changed tracks which columns have been modified since the last sync.

You do not define these in your schema file because WatermelonDB adds them automatically. However, you do need to add a server_id column to map local records to their Supabase counterparts.

  • Create a new folder src/database inside your project.
  • Create the file src/database/schema.ts and add the following schema definition:
import { appSchema, tableSchema } from '@nozbe/watermelondb'

export default appSchema({
  version: 1,
  tables: [
    // Warehouse: top-level container
    tableSchema({
      name: 'warehouses',
      columns: [
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    // Zone: a section within a warehouse
    tableSchema({
      name: 'zones',
      columns: [
        { name: 'warehouse_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    // Shelf: a physical shelf inside a zone
    tableSchema({
      name: 'shelves',
      columns: [
        { name: 'zone_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    // Slot: an individual space on a shelf
    tableSchema({
      name: 'slots',
      columns: [
        { name: 'shelf_id', type: 'string', isIndexed: true },
        { name: 'width_cm', type: 'number' },
        { name: 'height_cm', type: 'number' },
        { name: 'depth_cm', type: 'number' },
        { name: 'is_occupied', type: 'boolean' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    // Product: an item placed in a slot
    tableSchema({
      name: 'products',
      columns: [
        { name: 'slot_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'width_cm', type: 'number' },
        { name: 'height_cm', type: 'number' },
        { name: 'depth_cm', type: 'number' },
        { name: 'photo_local_path', type: 'string', isOptional: true },
        { name: 'photo_remote_url', type: 'string', isOptional: true },
        { name: 'placed_by', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    // Activity log: tracks who placed what, where, and when
    tableSchema({
      name: 'activity_log',
      columns: [
        { name: 'worker_id', type: 'string', isIndexed: true },
        { name: 'product_id', type: 'string', isIndexed: true },
        { name: 'slot_id', type: 'string', isIndexed: true },
        { name: 'action', type: 'string' },
        { name: 'timestamp', type: 'number' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
  ],
})

What does this code do?

  • appSchema wraps the entire database definition with a version number for migrations.
  • Each tableSchema defines one table with its columns, types, and indexes.
  • Foreign key columns (like warehouse_id) use isIndexed: true so WatermelonDB can look up related records quickly.
  • server_id is marked isOptional: true because locally-created records won't have a server ID until after their first sync.
  • Save src/database/schema.ts.

Seeing a TypeScript error on appSchema?

Make sure you imported both appSchema and tableSchema from @nozbe/watermelondb (not from a sub-path like @nozbe/watermelondb/Schema). Also confirm that @nozbe/watermelondb is listed in your package.json dependencies.

help me fix a TypeScript import error with WatermelonDB schema

Create Model classes for each table

Models are TypeScript classes that map to schema tables. They define how your code interacts with each record: reading fields, following relations to parent records, and querying child records.

  • Create the folder src/database/models.
  • Create src/database/models/Warehouse.ts with the following code:
import { Model } from '@nozbe/watermelondb'
import { field, children, lazy } from '@nozbe/watermelondb/decorators'

export default class Warehouse extends Model {
  static table = 'warehouses'

  static associations = {
    zones: { type: 'has_many' as const, foreignKey: 'warehouse_id' },
  }

  // Column fields
  @field('name') name!: string
  @field('server_id') serverId!: string | null

  // Lazy query for all zones in this warehouse
  @lazy zones = this.collections
    .get('zones')
    .query()
}

What does this code do?

  • static table ties this Model to the warehouses table in the schema.
  • static associations declares that a warehouse has many zones (linked by warehouse_id).
  • @field maps a class property to a schema column.
  • @lazy creates a query that only runs when accessed, keeping the app performant by not loading all zones upfront.
  • Create src/database/models/Zone.ts with this code:
import { Model } from '@nozbe/watermelondb'
import { field, relation, lazy } from '@nozbe/watermelondb/decorators'

export default class Zone extends Model {
  static table = 'zones'

  static associations = {
    warehouses: { type: 'belongs_to' as const, key: 'warehouse_id' },
    shelves: { type: 'has_many' as const, foreignKey: 'zone_id' },
  }

  // Column fields
  @field('warehouse_id') warehouseId!: string
  @field('name') name!: string
  @field('server_id') serverId!: string | null

  // Relation to parent warehouse
  @relation('warehouses', 'warehouse_id') warehouse!: any

  // Lazy query for all shelves in this zone
  @lazy shelves = this.collections
    .get('shelves')
    .query()
}
  • Create src/database/models/Shelf.ts:
import { Model } from '@nozbe/watermelondb'
import { field, relation, lazy } from '@nozbe/watermelondb/decorators'

export default class Shelf extends Model {
  static table = 'shelves'

  static associations = {
    zones: { type: 'belongs_to' as const, key: 'zone_id' },
    slots: { type: 'has_many' as const, foreignKey: 'shelf_id' },
  }

  // Column fields
  @field('zone_id') zoneId!: string
  @field('name') name!: string
  @field('server_id') serverId!: string | null

  // Relation to parent zone
  @relation('zones', 'zone_id') zone!: any

  // Lazy query for all slots on this shelf
  @lazy slots = this.collections
    .get('slots')
    .query()
}
  • Create src/database/models/Slot.ts:
import { Model } from '@nozbe/watermelondb'
import { field, relation, lazy } from '@nozbe/watermelondb/decorators'

export default class Slot extends Model {
  static table = 'slots'

  static associations = {
    shelves: { type: 'belongs_to' as const, key: 'shelf_id' },
    products: { type: 'has_many' as const, foreignKey: 'slot_id' },
  }

  // Column fields
  @field('shelf_id') shelfId!: string
  @field('width_cm') widthCm!: number
  @field('height_cm') heightCm!: number
  @field('depth_cm') depthCm!: number
  @field('is_occupied') isOccupied!: boolean
  @field('server_id') serverId!: string | null

  // Relation to parent shelf
  @relation('shelves', 'shelf_id') shelf!: any

  // Lazy query for products in this slot
  @lazy products = this.collections
    .get('products')
    .query()
}
  • Create src/database/models/Product.ts:
import { Model } from '@nozbe/watermelondb'
import { field, relation } from '@nozbe/watermelondb/decorators'

export default class Product extends Model {
  static table = 'products'

  static associations = {
    slots: { type: 'belongs_to' as const, key: 'slot_id' },
  }

  // Column fields
  @field('slot_id') slotId!: string
  @field('name') name!: string
  @field('width_cm') widthCm!: number
  @field('height_cm') heightCm!: number
  @field('depth_cm') depthCm!: number
  @field('photo_local_path') photoLocalPath!: string | null
  @field('photo_remote_url') photoRemoteUrl!: string | null
  @field('placed_by') placedBy!: string
  @field('server_id') serverId!: string | null

  // Relation to parent slot
  @relation('slots', 'slot_id') slot!: any
}
  • Create src/database/models/ActivityLog.ts:
import { Model } from '@nozbe/watermelondb'
import { field, relation } from '@nozbe/watermelondb/decorators'

export default class ActivityLog extends Model {
  static table = 'activity_log'

  static associations = {
    products: { type: 'belongs_to' as const, key: 'product_id' },
    slots: { type: 'belongs_to' as const, key: 'slot_id' },
  }

  // Column fields
  @field('worker_id') workerId!: string
  @field('product_id') productId!: string
  @field('slot_id') slotId!: string
  @field('action') action!: string
  @field('timestamp') timestamp!: number
  @field('server_id') serverId!: string | null

  // Relations to product and slot
  @relation('products', 'product_id') product!: any
  @relation('slots', 'slot_id') slot!: any
}

How do @relation and @lazy differ?

@relation fetches a single parent record (e.g., a slot's parent shelf). You pass the table name and the foreign key column.

@lazy defines a query for multiple child records (e.g., all slots on a shelf). It only executes when your code actually accesses the property, keeping memory usage low.

  • Save all six model files.

Decorator errors in your editor?

TypeScript decorators require the experimentalDecorators flag. Open your tsconfig.json and confirm that "experimentalDecorators": true is set in compilerOptions. Also verify your babel.config.js includes the decorator plugin as the first entry.

help me fix decorator errors in my WatermelonDB models

Initialize the database and wrap the app in DatabaseProvider

With the schema and models ready, you need to create the actual database instance and make it available to every component in the app via React context.

  • Create src/database/index.ts with the following initialization code:
import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'

import schema from './schema'
import Warehouse from './models/Warehouse'
import Zone from './models/Zone'
import Shelf from './models/Shelf'
import Slot from './models/Slot'
import Product from './models/Product'
import ActivityLog from './models/ActivityLog'

// Create the SQLite adapter with JSI for faster queries
const adapter = new SQLiteAdapter({
  schema,
  dbName: 'warehouse_db',
  jsi: true,
  onSetUpError: (error) => {
    console.error('[Database] Setup failed:', error)
  },
})

// Create the database instance with all models registered
const database = new Database({
  adapter,
  modelClasses: [
    Warehouse,
    Zone,
    Shelf,
    Slot,
    Product,
    ActivityLog,
  ],
})

export default database

What does this code do?

  • SQLiteAdapter connects WatermelonDB to the device's SQLite engine. Setting jsi: true enables the JavaScript Interface for 3-5x faster queries.
  • dbName: 'warehouse_db' names the SQLite file on disk.
  • modelClasses registers every Model so WatermelonDB knows how to read and write each table.
  • Save src/database/index.ts.
  • Open App.tsx and wrap your app in DatabaseProvider using the following code:
import React from 'react'
import { DatabaseProvider } from '@nozbe/watermelondb/DatabaseProvider'
import database from './src/database'
import { AuthProvider } from './src/context/AuthProvider'

export default function App() {
  return (
    <DatabaseProvider database={database}>
      <AuthProvider>
        {/* Your app screens go here */}
      </AuthProvider>
    </DatabaseProvider>
  )
}

Why does DatabaseProvider wrap AuthProvider?

DatabaseProvider gives every child component access to the WatermelonDB instance via React context. Because your authenticated screens need to read and write warehouse data, the database must be available before any screen renders. Wrapping it outside AuthProvider ensures the database is ready regardless of login state.

  • Save App.tsx.
  • Rebuild and run the app on your physical device by running:
npx expo run:android
  • You should see the app launch without any database-related crash errors in the terminal. The login screen appears as before.

App crashes on launch after adding the database?

  • Check that you ran a fresh native build (npx expo run:android). WatermelonDB includes native code that requires a rebuild.
  • Confirm jsi: true is supported on your device. If you see a JSI-related crash, try setting jsi: false temporarily to isolate the issue.
  • Verify your babel.config.js has the decorator plugin listed first. Missing decorators cause cryptic runtime errors.

help me debug a WatermelonDB crash on Android startup

✔️ Awesome, I've got everything!

Great. Double check you saved all files in src/database/ and App.tsx.

ⓧ I'd like to double check the full code

Here is the complete src/database/schema.ts:

import { appSchema, tableSchema } from '@nozbe/watermelondb'

export default appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'warehouses',
      columns: [
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    tableSchema({
      name: 'zones',
      columns: [
        { name: 'warehouse_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    tableSchema({
      name: 'shelves',
      columns: [
        { name: 'zone_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    tableSchema({
      name: 'slots',
      columns: [
        { name: 'shelf_id', type: 'string', isIndexed: true },
        { name: 'width_cm', type: 'number' },
        { name: 'height_cm', type: 'number' },
        { name: 'depth_cm', type: 'number' },
        { name: 'is_occupied', type: 'boolean' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    tableSchema({
      name: 'products',
      columns: [
        { name: 'slot_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'width_cm', type: 'number' },
        { name: 'height_cm', type: 'number' },
        { name: 'depth_cm', type: 'number' },
        { name: 'photo_local_path', type: 'string', isOptional: true },
        { name: 'photo_remote_url', type: 'string', isOptional: true },
        { name: 'placed_by', type: 'string' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
    tableSchema({
      name: 'activity_log',
      columns: [
        { name: 'worker_id', type: 'string', isIndexed: true },
        { name: 'product_id', type: 'string', isIndexed: true },
        { name: 'slot_id', type: 'string', isIndexed: true },
        { name: 'action', type: 'string' },
        { name: 'timestamp', type: 'number' },
        { name: 'server_id', type: 'string', isOptional: true },
      ],
    }),
  ],
})

Here is src/database/index.ts:

import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'

import schema from './schema'
import Warehouse from './models/Warehouse'
import Zone from './models/Zone'
import Shelf from './models/Shelf'
import Slot from './models/Slot'
import Product from './models/Product'
import ActivityLog from './models/ActivityLog'

const adapter = new SQLiteAdapter({
  schema,
  dbName: 'warehouse_db',
  jsi: true,
  onSetUpError: (error) => {
    console.error('[Database] Setup failed:', error)
  },
})

const database = new Database({
  adapter,
  modelClasses: [
    Warehouse,
    Zone,
    Shelf,
    Slot,
    Product,
    ActivityLog,
  ],
})

export default database

Here is App.tsx:

import React from 'react'
import { DatabaseProvider } from '@nozbe/watermelondb/DatabaseProvider'
import database from './src/database'
import { AuthProvider } from './src/context/AuthProvider'

export default function App() {
  return (
    <DatabaseProvider database={database}>
      <AuthProvider>
        {/* Your app screens go here */}
      </AuthProvider>
    </DatabaseProvider>
  )
}

Your local database is ready. Every table in the warehouse hierarchy now lives on-device in SQLite, and any component in your app can access it through DatabaseProvider. Next up, you'll build the camera layer and connect it to on-device AI for measuring products.

Build the Camera and AI Inference Layer

Your database and auth layers are ready. Now it's time to build the intelligence layer that makes this app special.

In this step, you'll configure on-device AI inference using Gemma 4 E2B, build a camera screen with a reference object overlay, and create the service that turns product photos into structured dimension estimates. All of this runs entirely on the physical device with zero internet dependency.

In this step, get ready to:
  • Configure the AI core and download the Gemma 4 E2B model to the device.
  • Build a camera screen with a reference object overlay for consistent measurements.
  • Implement AI inference functions that return structured JSON dimensions.
Configure the AI core and model download

The react-native-ai-core library manages the full AI lifecycle: downloading the model, initializing it, and running inference. You need to enable vision support before loading the model because image inference uses a separate GPU backend.

  • Add your HuggingFace token to the .env file. The Gemma 4 E2B model is gated, so a free HuggingFace Read token is required for download.
  • Open .env and add this line below your existing Supabase variables:
EXPO_PUBLIC_HF_TOKEN=your-hf-token-here

Where do I get a HuggingFace token?

Create a free account at huggingface.co. Go to your profile settings, then Access Tokens. Generate a new token with Read permissions.

You also need to visit the Gemma 4 model page on HuggingFace and accept the license agreement before the download will work.

  • Create the AI service file by adding src/services/aiService.ts. This file holds the initialization logic, prompt templates, and inference functions.
  • Add the following code to set up imports, prompt constants, and the initAI function:
import { AICore, KnownModel } from 'react-native-ai-core';
import * as FileSystem from 'expo-file-system';

// HuggingFace token for gated model access
const HF_TOKEN = process.env.EXPO_PUBLIC_HF_TOKEN || '';

// Prompt template for estimating product dimensions from a photo
const PRODUCT_MEASUREMENT_PROMPT = `You are a warehouse measurement assistant. Analyze this image of a product.
A reference object (credit card: 8.5cm x 5.4cm, or A4 paper: 29.7cm x 21cm) is visible in the frame.
Using the reference object for scale, estimate the product dimensions.

Return ONLY valid JSON in this exact format:
{
  "width_cm": <number>,
  "height_cm": <number>,
  "depth_cm": <number>,
  "confidence": <number between 0 and 1>,
  "reference_object_detected": <"credit_card" | "a4_paper" | "none">
}

If you cannot clearly see the reference object or product, set confidence below 0.6.`;

// Prompt template for analyzing shelf slots from a photo
const SHELF_ANALYSIS_PROMPT = `You are a warehouse shelf analyst. Analyze this image of a warehouse shelf.
Identify all available slots (compartments) on the shelf.
For each slot, estimate its dimensions and whether it is currently occupied.

Return ONLY valid JSON in this exact format:
{
  "slots": [
    {
      "slot_index": <number starting at 1>,
      "width_cm": <number>,
      "height_cm": <number>,
      "depth_cm": <number>,
      "is_occupied": <boolean>
    }
  ],
  "total_slots": <number>,
  "confidence": <number between 0 and 1>
}`;

// Custom error thrown when AI confidence is too low
export class RetakeRequiredError extends Error {
  constructor(confidence: number) {
    super(`Low confidence (${confidence}). Please retake the photo.`);
    this.name = 'RetakeRequiredError';
  }
}

// Initialize AI core with vision support and download the model
export async function initAI(
  onProgress?: (progress: number) => void,
  onStatus?: (status: string) => void
) {
  // Enable vision backend before loading model
  AICore.configure({ enableVision: true });

  // Download and initialize Gemma 4 E2B (~2.4 GB)
  await AICore.ensureModel(KnownModel.GEMMA4_2B, {
    hfToken: HF_TOKEN,
    onProgress,
    onStatus,
  });
}

What does this code do?

AICore.configure({ enableVision: true }) activates the GPU vision backend. This must be called before ensureModel or image inference will fail silently.

AICore.ensureModel(KnownModel.GEMMA4_2B, { hfToken }) handles the entire model lifecycle: checking if the model exists locally, downloading it if not (~2.4 GB), and initializing it for inference. The onProgress callback lets you show download progress to the user.

The two prompt constants instruct the model to return structured JSON. Explicit format instructions help small on-device models produce parseable output.

  • Save src/services/aiService.ts.

You won't see a change yet. The next chunk builds the download progress screen that calls initAI on app startup.

  • Create src/components/ModelDownloadScreen.tsx. This full-screen component displays download progress during the first launch when the model has not been cached yet:
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';

interface ModelDownloadScreenProps {
  progress: number; // 0 to 1
  status: string;
}

// Full-screen overlay shown during first-time model download
export default function ModelDownloadScreen({ progress, status }: ModelDownloadScreenProps) {
  const percentage = Math.round(progress * 100);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Downloading AI Model</Text>
      <Text style={styles.subtitle}>Gemma 4 E2B (~2.4 GB)</Text>

      {/* Progress bar */}
      <View style={styles.progressBarBackground}>
        <View style={[styles.progressBarFill, { width: `${percentage}%` }]} />
      </View>

      <Text style={styles.percentage}>{percentage}%</Text>
      <Text style={styles.status}>{status}</Text>

      <ActivityIndicator style={styles.spinner} color="#4A90D9" />
      <Text style={styles.note}>
        This only happens once. The model is cached on your device for offline use.
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#1a1a2e',
    padding: 32,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#ffffff',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 16,
    color: '#a0a0b0',
    marginBottom: 32,
  },
  progressBarBackground: {
    width: '100%',
    height: 12,
    backgroundColor: '#2a2a3e',
    borderRadius: 6,
    overflow: 'hidden',
  },
  progressBarFill: {
    height: '100%',
    backgroundColor: '#4A90D9',
    borderRadius: 6,
  },
  percentage: {
    fontSize: 32,
    fontWeight: 'bold',
    color: '#ffffff',
    marginTop: 16,
  },
  status: {
    fontSize: 14,
    color: '#a0a0b0',
    marginTop: 8,
  },
  spinner: {
    marginTop: 24,
  },
  note: {
    fontSize: 12,
    color: '#707080',
    textAlign: 'center',
    marginTop: 24,
  },
});

What does this component do?

ModelDownloadScreen is a full-screen overlay that displays while initAI() downloads the Gemma 4 E2B model on first launch. It receives progress (0 to 1) and status from the onProgress and onStatus callbacks you passed to ensureModel.

Your App.tsx calls initAI() on startup and conditionally renders this screen when a download is in progress. After the model is cached locally, the screen never appears again.

  • Save src/components/ModelDownloadScreen.tsx.
  • Run the app on your physical device. On first launch, you should see the model download screen with a progress bar filling up as the ~2.4 GB model downloads.

Download not starting?

Make sure your HuggingFace token is correct and that you accepted the Gemma 4 model license on the HuggingFace website. The download requires an active internet connection for the first launch only.

If you see an authentication error, double-check that your token has Read scope and that EXPO_PUBLIC_HF_TOKEN is set in your .env file.

help me debug my AI model download

Build the camera screen

Workers need to photograph products with a reference object in the frame for the AI to estimate real-world dimensions. The camera screen provides a semi-transparent overlay guiding them to include a credit card or A4 paper.

  • Create src/screens/CameraScreen.tsx with the following code:
import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useNavigation, useRoute } from '@react-navigation/native';

interface CameraScreenProps {
  mode: 'product' | 'shelf';
}

// Camera screen with reference object overlay for consistent measurements
export default function CameraScreen() {
  const route = useRoute();
  const navigation = useNavigation();
  const { mode } = (route.params as CameraScreenProps) || { mode: 'product' };
  const cameraRef = useRef<CameraView>(null);
  const [permission, requestPermission] = useCameraPermissions();

  // Handle permission states
  if (!permission) {
    return <View style={styles.container} />;
  }

  if (!permission.granted) {
    return (
      <View style={styles.permissionContainer}>
        <Text style={styles.permissionText}>
          Camera access is required to photograph products and shelves.
        </Text>
        <TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
          <Text style={styles.permissionButtonText}>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

  // Capture photo and navigate to confirmation screen
  const handleCapture = async () => {
    if (!cameraRef.current) return;

    const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
    if (!photo) return;

    // Navigate to the appropriate confirmation screen based on mode
    if (mode === 'product') {
      navigation.navigate('ProductMeasure' as never, { imageUri: photo.uri } as never);
    } else {
      navigation.navigate('ShelfConfirmation' as never, { imageUri: photo.uri } as never);
    }
  };

  return (
    <View style={styles.container}>
      <CameraView ref={cameraRef} style={styles.camera} facing="back">
        {/* Semi-transparent overlay with reference object guide */}
        <View style={styles.overlay}>
          <View style={styles.referenceGuide}>
            <Text style={styles.referenceText}>
              Place credit card or A4 paper here
            </Text>
          </View>
        </View>
      </CameraView>

      {/* Capture button */}
      <View style={styles.controls}>
        <TouchableOpacity style={styles.captureButton} onPress={handleCapture}>
          <View style={styles.captureInner} />
        </TouchableOpacity>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#000',
  },
  camera: {
    flex: 1,
  },
  overlay: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'flex-end',
    alignItems: 'center',
    paddingBottom: 140,
  },
  referenceGuide: {
    width: 200,
    height: 126,
    borderWidth: 2,
    borderColor: 'rgba(255, 255, 255, 0.7)',
    borderStyle: 'dashed',
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
  },
  referenceText: {
    color: 'rgba(255, 255, 255, 0.9)',
    fontSize: 12,
    textAlign: 'center',
    paddingHorizontal: 8,
  },
  controls: {
    position: 'absolute',
    bottom: 40,
    width: '100%',
    alignItems: 'center',
  },
  captureButton: {
    width: 72,
    height: 72,
    borderRadius: 36,
    backgroundColor: 'rgba(255, 255, 255, 0.3)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  captureInner: {
    width: 58,
    height: 58,
    borderRadius: 29,
    backgroundColor: '#ffffff',
  },
  permissionContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#1a1a2e',
    padding: 32,
  },
  permissionText: {
    color: '#ffffff',
    fontSize: 16,
    textAlign: 'center',
    marginBottom: 24,
  },
  permissionButton: {
    backgroundColor: '#4A90D9',
    paddingHorizontal: 24,
    paddingVertical: 12,
    borderRadius: 8,
  },
  permissionButtonText: {
    color: '#ffffff',
    fontSize: 16,
    fontWeight: '600',
  },
});

What does this code do?

useCameraPermissions() from expo-camera handles the Android runtime permission flow. The component renders a permission request screen if access has not been granted yet.

The referenceGuide overlay is a dashed-border rectangle that guides the worker to place a credit card or A4 paper in the frame. The AI uses this known-size object to calculate real-world dimensions from the photo.

takePictureAsync({ quality: 0.8 }) captures a photo at 80% JPEG quality. The mode prop determines whether the captured image routes to the product measurement flow or the shelf analysis flow.

  • Save src/screens/CameraScreen.tsx.
  • Run the app on your device and navigate to the camera screen. You should see the live camera feed with a dashed rectangle overlay labeled Place credit card or A4 paper here and a circular capture button at the bottom.

Camera shows a black screen?

This must run on a physical Android device. Emulators do not reliably support the camera preview with AI Edge SDKs.

Check that your app.json includes the expo-camera plugin and that you have rebuilt the dev client after adding it. Permission changes require a fresh native build.

help me fix my camera screen

Implement the AI inference functions

With the model downloaded and the camera ready, you can now wire up the inference pipeline. Each function reads the captured image, converts it to base64, sends it to the on-device model with the appropriate prompt, and parses the structured JSON response.

  • Open src/services/aiService.ts and add the inferDimensions function below the initAI function:
// Infer product dimensions from a photo using on-device AI
export async function inferDimensions(imageUri: string): Promise<{
  width_cm: number;
  height_cm: number;
  depth_cm: number;
  confidence: number;
}> {
  // Read the image file and convert to base64
  const base64 = await FileSystem.readAsStringAsync(imageUri, {
    encoding: FileSystem.EncodingType.Base64,
  });

  // Send image + prompt to on-device Gemma 4 E2B
  const response = await AICore.generateResponseWithImage(
    base64,
    PRODUCT_MEASUREMENT_PROMPT
  );

  // Parse the JSON response from the model
  const result = JSON.parse(response);

  // Reject low-confidence results
  if (result.confidence < 0.6) {
    throw new RetakeRequiredError(result.confidence);
  }

  return {
    width_cm: result.width_cm,
    height_cm: result.height_cm,
    depth_cm: result.depth_cm,
    confidence: result.confidence,
  };
}

What does inferDimensions do?

FileSystem.readAsStringAsync reads the captured photo and encodes it as base64. This is the format react-native-ai-core expects for vision inference.

AICore.generateResponseWithImage(base64, prompt) sends the image and text prompt to the Gemma 4 E2B model running locally on the device. No network call happens here.

If the model's confidence score is below 0.6, the function throws a RetakeRequiredError. The UI layer catches this and prompts the worker to take another photo with better framing.

  • Now add the inferShelfSlots function below inferDimensions in the same file:
// Infer shelf slot layout from a photo using on-device AI
export async function inferShelfSlots(imageUri: string): Promise<{
  slots: Array<{
    slot_index: number;
    width_cm: number;
    height_cm: number;
    depth_cm: number;
    is_occupied: boolean;
  }>;
  total_slots: number;
  confidence: number;
}> {
  // Read the shelf image and convert to base64
  const base64 = await FileSystem.readAsStringAsync(imageUri, {
    encoding: FileSystem.EncodingType.Base64,
  });

  // Send image + shelf analysis prompt to on-device model
  const response = await AICore.generateResponseWithImage(
    base64,
    SHELF_ANALYSIS_PROMPT
  );

  // Parse and return the structured slot data
  const result = JSON.parse(response);

  return {
    slots: result.slots,
    total_slots: result.total_slots,
    confidence: result.confidence,
  };
}

What does inferShelfSlots do?

This function follows the same pattern as inferDimensions but uses the SHELF_ANALYSIS_PROMPT instead. It returns an array of detected slots with their dimensions and occupancy status.

You will use this in the Shelf Analysis Flow (Step 7) when a worker photographs a shelf to register its available slots.

  • Save src/services/aiService.ts.
  • Test the full flow: open the camera, take a photo of any object next to a credit card, and confirm the app navigates to the next screen. The measurement confirmation screen is built in Step 6, so for now just confirm navigation does not crash.

Seeing a JSON parse error?

Small on-device models occasionally return malformed JSON. If JSON.parse fails, wrap the call in a try/catch and retry once. You can also strip any text before the first { and after the last } to clean stray model output.

help me handle JSON parse errors from on-device AI

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/services/aiService.ts file:

import { AICore, KnownModel } from 'react-native-ai-core';
import * as FileSystem from 'expo-file-system';

// HuggingFace token for gated model access
const HF_TOKEN = process.env.EXPO_PUBLIC_HF_TOKEN || '';

// Prompt template for estimating product dimensions from a photo
const PRODUCT_MEASUREMENT_PROMPT = `You are a warehouse measurement assistant. Analyze this image of a product.
A reference object (credit card: 8.5cm x 5.4cm, or A4 paper: 29.7cm x 21cm) is visible in the frame.
Using the reference object for scale, estimate the product dimensions.

Return ONLY valid JSON in this exact format:
{
  "width_cm": <number>,
  "height_cm": <number>,
  "depth_cm": <number>,
  "confidence": <number between 0 and 1>,
  "reference_object_detected": <"credit_card" | "a4_paper" | "none">
}

If you cannot clearly see the reference object or product, set confidence below 0.6.`;

// Prompt template for analyzing shelf slots from a photo
const SHELF_ANALYSIS_PROMPT = `You are a warehouse shelf analyst. Analyze this image of a warehouse shelf.
Identify all available slots (compartments) on the shelf.
For each slot, estimate its dimensions and whether it is currently occupied.

Return ONLY valid JSON in this exact format:
{
  "slots": [
    {
      "slot_index": <number starting at 1>,
      "width_cm": <number>,
      "height_cm": <number>,
      "depth_cm": <number>,
      "is_occupied": <boolean>
    }
  ],
  "total_slots": <number>,
  "confidence": <number between 0 and 1>
}`;

// Custom error thrown when AI confidence is too low
export class RetakeRequiredError extends Error {
  constructor(confidence: number) {
    super(`Low confidence (${confidence}). Please retake the photo.`);
    this.name = 'RetakeRequiredError';
  }
}

// Initialize AI core with vision support and download the model
export async function initAI(
  onProgress?: (progress: number) => void,
  onStatus?: (status: string) => void
) {
  // Enable vision backend before loading model
  AICore.configure({ enableVision: true });

  // Download and initialize Gemma 4 E2B (~2.4 GB)
  await AICore.ensureModel(KnownModel.GEMMA4_2B, {
    hfToken: HF_TOKEN,
    onProgress,
    onStatus,
  });
}

// Infer product dimensions from a photo using on-device AI
export async function inferDimensions(imageUri: string): Promise<{
  width_cm: number;
  height_cm: number;
  depth_cm: number;
  confidence: number;
}> {
  // Read the image file and convert to base64
  const base64 = await FileSystem.readAsStringAsync(imageUri, {
    encoding: FileSystem.EncodingType.Base64,
  });

  // Send image + prompt to on-device Gemma 4 E2B
  const response = await AICore.generateResponseWithImage(
    base64,
    PRODUCT_MEASUREMENT_PROMPT
  );

  // Parse the JSON response from the model
  const result = JSON.parse(response);

  // Reject low-confidence results
  if (result.confidence < 0.6) {
    throw new RetakeRequiredError(result.confidence);
  }

  return {
    width_cm: result.width_cm,
    height_cm: result.height_cm,
    depth_cm: result.depth_cm,
    confidence: result.confidence,
  };
}

// Infer shelf slot layout from a photo using on-device AI
export async function inferShelfSlots(imageUri: string): Promise<{
  slots: Array<{
    slot_index: number;
    width_cm: number;
    height_cm: number;
    depth_cm: number;
    is_occupied: boolean;
  }>;
  total_slots: number;
  confidence: number;
}> {
  // Read the shelf image and convert to base64
  const base64 = await FileSystem.readAsStringAsync(imageUri, {
    encoding: FileSystem.EncodingType.Base64,
  });

  // Send image + shelf analysis prompt to on-device model
  const response = await AICore.generateResponseWithImage(
    base64,
    SHELF_ANALYSIS_PROMPT
  );

  // Parse and return the structured slot data
  const result = JSON.parse(response);

  return {
    slots: result.slots,
    total_slots: result.total_slots,
    confidence: result.confidence,
  };
}

Here is the complete src/screens/CameraScreen.tsx:

import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useNavigation, useRoute } from '@react-navigation/native';

interface CameraScreenProps {
  mode: 'product' | 'shelf';
}

// Camera screen with reference object overlay for consistent measurements
export default function CameraScreen() {
  const route = useRoute();
  const navigation = useNavigation();
  const { mode } = (route.params as CameraScreenProps) || { mode: 'product' };
  const cameraRef = useRef<CameraView>(null);
  const [permission, requestPermission] = useCameraPermissions();

  // Handle permission states
  if (!permission) {
    return <View style={styles.container} />;
  }

  if (!permission.granted) {
    return (
      <View style={styles.permissionContainer}>
        <Text style={styles.permissionText}>
          Camera access is required to photograph products and shelves.
        </Text>
        <TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
          <Text style={styles.permissionButtonText}>Grant Permission</Text>
        </TouchableOpacity>
      </View>
    );
  }

  // Capture photo and navigate to confirmation screen
  const handleCapture = async () => {
    if (!cameraRef.current) return;

    const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
    if (!photo) return;

    // Navigate to the appropriate confirmation screen based on mode
    if (mode === 'product') {
      navigation.navigate('ProductMeasure' as never, { imageUri: photo.uri } as never);
    } else {
      navigation.navigate('ShelfConfirmation' as never, { imageUri: photo.uri } as never);
    }
  };

  return (
    <View style={styles.container}>
      <CameraView ref={cameraRef} style={styles.camera} facing="back">
        {/* Semi-transparent overlay with reference object guide */}
        <View style={styles.overlay}>
          <View style={styles.referenceGuide}>
            <Text style={styles.referenceText}>
              Place credit card or A4 paper here
            </Text>
          </View>
        </View>
      </CameraView>

      {/* Capture button */}
      <View style={styles.controls}>
        <TouchableOpacity style={styles.captureButton} onPress={handleCapture}>
          <View style={styles.captureInner} />
        </TouchableOpacity>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#000',
  },
  camera: {
    flex: 1,
  },
  overlay: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'flex-end',
    alignItems: 'center',
    paddingBottom: 140,
  },
  referenceGuide: {
    width: 200,
    height: 126,
    borderWidth: 2,
    borderColor: 'rgba(255, 255, 255, 0.7)',
    borderStyle: 'dashed',
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
  },
  referenceText: {
    color: 'rgba(255, 255, 255, 0.9)',
    fontSize: 12,
    textAlign: 'center',
    paddingHorizontal: 8,
  },
  controls: {
    position: 'absolute',
    bottom: 40,
    width: '100%',
    alignItems: 'center',
  },
  captureButton: {
    width: 72,
    height: 72,
    borderRadius: 36,
    backgroundColor: 'rgba(255, 255, 255, 0.3)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  captureInner: {
    width: 58,
    height: 58,
    borderRadius: 29,
    backgroundColor: '#ffffff',
  },
  permissionContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#1a1a2e',
    padding: 32,
  },
  permissionText: {
    color: '#ffffff',
    fontSize: 16,
    textAlign: 'center',
    marginBottom: 24,
  },
  permissionButton: {
    backgroundColor: '#4A90D9',
    paddingHorizontal: 24,
    paddingVertical: 12,
    borderRadius: 8,
  },
  permissionButtonText: {
    color: '#ffffff',
    fontSize: 16,
    fontWeight: '600',
  },
});

And the complete src/components/ModelDownloadScreen.tsx:

import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';

interface ModelDownloadScreenProps {
  progress: number; // 0 to 1
  status: string;
}

// Full-screen overlay shown during first-time model download
export default function ModelDownloadScreen({ progress, status }: ModelDownloadScreenProps) {
  const percentage = Math.round(progress * 100);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Downloading AI Model</Text>
      <Text style={styles.subtitle}>Gemma 4 E2B (~2.4 GB)</Text>

      {/* Progress bar */}
      <View style={styles.progressBarBackground}>
        <View style={[styles.progressBarFill, { width: `${percentage}%` }]} />
      </View>

      <Text style={styles.percentage}>{percentage}%</Text>
      <Text style={styles.status}>{status}</Text>

      <ActivityIndicator style={styles.spinner} color="#4A90D9" />
      <Text style={styles.note}>
        This only happens once. The model is cached on your device for offline use.
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#1a1a2e',
    padding: 32,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#ffffff',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 16,
    color: '#a0a0b0',
    marginBottom: 32,
  },
  progressBarBackground: {
    width: '100%',
    height: 12,
    backgroundColor: '#2a2a3e',
    borderRadius: 6,
    overflow: 'hidden',
  },
  progressBarFill: {
    height: '100%',
    backgroundColor: '#4A90D9',
    borderRadius: 6,
  },
  percentage: {
    fontSize: 32,
    fontWeight: 'bold',
    color: '#ffffff',
    marginTop: 16,
  },
  status: {
    fontSize: 14,
    color: '#a0a0b0',
    marginTop: 8,
  },
  spinner: {
    marginTop: 24,
  },
  note: {
    fontSize: 12,
    color: '#707080',
    textAlign: 'center',
    marginTop: 24,
  },
});

Your AI inference layer is fully wired up. The camera captures photos, the on-device model estimates dimensions, and low-confidence results trigger a retake. Next up, you will build the product measurement flow that ties the camera and inference together into a complete user experience.

Build the Product Measurement Flow

Your camera captures photos and your AI service can estimate dimensions from those photos. The missing piece is the user-facing flow that ties them together.

In this step, you will build the two screens that make up the product measurement pipeline. The first screen sends the captured photo to on-device AI and shows estimated dimensions with a confidence score. The second screen lets the worker review, adjust, and confirm those measurements before saving the product to WatermelonDB.

In this step, get ready to:
  • Build the ProductMeasureScreen that calls on-device AI and displays dimension estimates.
  • Build the MeasurementConfirmationScreen with editable dimension fields and a product name input.
  • Save confirmed products to the local WatermelonDB database with sync-ready metadata.
Build the ProductMeasureScreen

After a worker takes a photo on the CameraScreen, they land on this screen. It sends the image to the on-device Gemma 4 E2B model, shows a loading spinner, then displays the AI-estimated height, width, and depth alongside a confidence percentage.

  • Create a new file at src/screens/ProductMeasureScreen.tsx and add the following code:
import React, { useState, useEffect } from 'react';
import { View, Text, ActivityIndicator, TouchableOpacity, StyleSheet } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { inferDimensions } from '../services/aiService';

export default function ProductMeasureScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const { imageUri } = route.params as { imageUri: string };

  // State for AI inference results
  const [loading, setLoading] = useState(true);
  const [dimensions, setDimensions] = useState<{
    height_cm: number;
    width_cm: number;
    depth_cm: number;
    confidence: number;
  } | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    runInference();
  }, []);

  // Send the captured image to on-device AI for dimension estimation
  const runInference = async () => {
    setLoading(true);
    setError(null);
    try {
      const result = await inferDimensions(imageUri);
      setDimensions(result);
    } catch (err: any) {
      setError(err.message || 'Failed to estimate dimensions');
    } finally {
      setLoading(false);
    }
  };

  // Navigate back to camera for a new photo
  const handleRetake = () => {
    navigation.navigate('Camera', { mode: 'product' });
  };

  // Pass AI results to the confirmation screen
  const handleConfirm = () => {
    if (!dimensions) return;
    navigation.navigate('MeasurementConfirmation', {
      imageUri,
      height_cm: dimensions.height_cm,
      width_cm: dimensions.width_cm,
      depth_cm: dimensions.depth_cm,
    });
  };

  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#4F46E5" />
        <Text style={styles.loadingText}>Analyzing product dimensions...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>{error}</Text>
        <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
          <Text style={styles.retakeButtonText}>Retake Photo</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Estimated Dimensions</Text>

      <View style={styles.dimensionsCard}>
        <View style={styles.dimensionRow}>
          <Text style={styles.label}>Height</Text>
          <Text style={styles.value}>{dimensions?.height_cm} cm</Text>
        </View>
        <View style={styles.dimensionRow}>
          <Text style={styles.label}>Width</Text>
          <Text style={styles.value}>{dimensions?.width_cm} cm</Text>
        </View>
        <View style={styles.dimensionRow}>
          <Text style={styles.label}>Depth</Text>
          <Text style={styles.value}>{dimensions?.depth_cm} cm</Text>
        </View>
      </View>

      {/* Confidence indicator */}
      <View style={styles.confidenceContainer}>
        <Text style={styles.confidenceLabel}>Confidence</Text>
        <Text style={[
          styles.confidenceValue,
          { color: (dimensions?.confidence ?? 0) >= 0.6 ? '#16A34A' : '#DC2626' }
        ]}>
          {Math.round((dimensions?.confidence ?? 0) * 100)}%
        </Text>
      </View>

      {/* Show retake button if confidence is low */}
      {(dimensions?.confidence ?? 0) < 0.6 && (
        <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
          <Text style={styles.retakeButtonText}>Retake Photo</Text>
        </TouchableOpacity>
      )}

      <TouchableOpacity style={styles.confirmButton} onPress={handleConfirm}>
        <Text style={styles.confirmButtonText}>Confirm Dimensions</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, backgroundColor: '#F9FAFB' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  title: { fontSize: 24, fontWeight: '700', marginBottom: 24, color: '#111827' },
  loadingText: { marginTop: 16, fontSize: 16, color: '#6B7280' },
  errorText: { fontSize: 16, color: '#DC2626', textAlign: 'center', marginBottom: 16 },
  dimensionsCard: { backgroundColor: '#FFFFFF', borderRadius: 12, padding: 20, marginBottom: 20 },
  dimensionRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F3F4F6' },
  label: { fontSize: 16, color: '#6B7280' },
  value: { fontSize: 18, fontWeight: '600', color: '#111827' },
  confidenceContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
  confidenceLabel: { fontSize: 16, color: '#6B7280' },
  confidenceValue: { fontSize: 20, fontWeight: '700' },
  retakeButton: { backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', borderRadius: 8, padding: 14, alignItems: 'center', marginBottom: 12 },
  retakeButtonText: { color: '#DC2626', fontSize: 16, fontWeight: '600' },
  confirmButton: { backgroundColor: '#4F46E5', borderRadius: 8, padding: 16, alignItems: 'center' },
  confirmButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

What does this code do?

This screen receives the imageUri from the CameraScreen via navigation params. On mount, it calls inferDimensions(imageUri) from your AI service, which runs the Gemma 4 E2B model entirely on-device.

The confidence indicator uses a 0.6 threshold. If the AI returns a confidence below 60%, the screen shows a red-colored percentage and a prominent Retake button, encouraging the worker to try again with a better photo. The worker can still proceed to confirm if they choose to.

  • Save src/screens/ProductMeasureScreen.tsx.

You won't see a change yet because this screen requires navigation from the CameraScreen. The next screen you build will complete the flow so you can test end-to-end.

Seeing a TypeScript error on route.params?

If TypeScript complains about the params type, make sure your navigation type definitions include ProductMeasure with an imageUri param in your stack navigator's param list. The type assertion as { imageUri: string } handles it at runtime, but your navigator types should match.

Help me fix TypeScript errors on my navigation route params in ProductMeasureScreen.

Build the MeasurementConfirmationScreen

AI estimates are helpful, but workers need the ability to adjust measurements before committing. This screen shows editable fields pre-filled with the AI results and saves the final product record to WatermelonDB.

  • Create a new file at src/screens/MeasurementConfirmationScreen.tsx and add the following code:
import React, { useState } from 'react';
import { View, Text, TextInput, Image, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { useDatabase } from '@nozbe/watermelondb/hooks';
import { useAuth } from '../context/AuthProvider';

export default function MeasurementConfirmationScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const database = useDatabase();
  const { session } = useAuth();

  // Extract AI-estimated dimensions from navigation params
  const { imageUri, height_cm, width_cm, depth_cm } = route.params as {
    imageUri: string;
    height_cm: number;
    width_cm: number;
    depth_cm: number;
  };

  // Editable fields pre-filled with AI estimates
  const [name, setName] = useState('');
  const [height, setHeight] = useState(String(height_cm));
  const [width, setWidth] = useState(String(width_cm));
  const [depth, setDepth] = useState(String(depth_cm));

  // Save product to WatermelonDB with sync-ready metadata
  const handleConfirm = async () => {
    if (!name.trim()) {
      Alert.alert('Missing Name', 'Please enter a product name.');
      return;
    }

    try {
      const productsCollection = database.get('products');
      const newProduct = await database.write(async () => {
        return await productsCollection.create((product: any) => {
          product.name = name.trim();
          product.widthCm = parseFloat(width);
          product.heightCm = parseFloat(height);
          product.depthCm = parseFloat(depth);
          product.photoLocalPath = imageUri;
          product.placedBy = session?.user?.id ?? '';
          product._raw._status = 'created';
        });
      });

      // Navigate to placement recommendations with the new product
      navigation.navigate('PlacementRecommendation', { productId: newProduct.id });
    } catch (err) {
      Alert.alert('Error', 'Failed to save product. Please try again.');
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Confirm Measurements</Text>

      {/* Product photo thumbnail */}
      <Image source={{ uri: imageUri }} style={styles.thumbnail} />

      {/* Product name input */}
      <Text style={styles.label}>Product Name</Text>
      <TextInput
        style={styles.input}
        value={name}
        onChangeText={setName}
        placeholder="Enter product name"
      />

      {/* Editable dimension fields */}
      <Text style={styles.label}>Height (cm)</Text>
      <TextInput
        style={styles.input}
        value={height}
        onChangeText={setHeight}
        keyboardType="numeric"
      />

      <Text style={styles.label}>Width (cm)</Text>
      <TextInput
        style={styles.input}
        value={width}
        onChangeText={setWidth}
        keyboardType="numeric"
      />

      <Text style={styles.label}>Depth (cm)</Text>
      <TextInput
        style={styles.input}
        value={depth}
        onChangeText={setDepth}
        keyboardType="numeric"
      />

      <TouchableOpacity style={styles.confirmButton} onPress={handleConfirm}>
        <Text style={styles.confirmButtonText}>Save Product</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, backgroundColor: '#F9FAFB' },
  title: { fontSize: 24, fontWeight: '700', marginBottom: 16, color: '#111827' },
  thumbnail: { width: '100%', height: 160, borderRadius: 12, marginBottom: 20, backgroundColor: '#E5E7EB' },
  label: { fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 6, marginTop: 12 },
  input: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#D1D5DB', borderRadius: 8, padding: 12, fontSize: 16 },
  confirmButton: { backgroundColor: '#4F46E5', borderRadius: 8, padding: 16, alignItems: 'center', marginTop: 24 },
  confirmButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

What does this code do?

  • useDatabase() grabs the WatermelonDB instance from the DatabaseProvider that wraps your app. This is how you access collections and write data from any component.
  • useAuth() pulls the current session so you can tag each product with the worker who created it via placedBy.
  • database.write() is required for all WatermelonDB modifications. Inside the writer, productsCollection.create() builds a new product record with the builder function pattern.
  • Setting product._raw._status = 'created' marks this record as needing to be pushed to the server on the next sync cycle.

Why save the photo as a local path?

The photoLocalPath field stores the local file URI from expo-file-system. This keeps the app functional offline. In a later step, you will build a background photo upload queue that pushes these local images to Supabase Storage when the device reconnects.

  • Save src/screens/MeasurementConfirmationScreen.tsx.
  • Run the app on your physical device, navigate to the product measurement flow (tap the camera button), photograph a product with a reference object visible, and tap Confirm Dimensions on the results screen.
  • On the confirmation screen, enter a product name, optionally adjust the dimension values, and tap Save Product. The app should navigate to the PlacementRecommendation screen (which you will build in the next step).

App crashes when tapping Save Product?

Make sure your app is wrapped in DatabaseProvider at the root level in App.tsx. The useDatabase() hook requires this context to resolve the database instance.

Also verify that your Product model field names (widthCm, heightCm, depthCm, photoLocalPath, placedBy) match exactly what you defined in src/database/models/Product.ts. A mismatch causes a silent crash inside the builder function.

Help me debug a crash when saving a product to WatermelonDB in my MeasurementConfirmationScreen.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the full ProductMeasureScreen.tsx:

import React, { useState, useEffect } from 'react';
import { View, Text, ActivityIndicator, TouchableOpacity, StyleSheet } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { inferDimensions } from '../services/aiService';

export default function ProductMeasureScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const { imageUri } = route.params as { imageUri: string };

  const [loading, setLoading] = useState(true);
  const [dimensions, setDimensions] = useState<{
    height_cm: number;
    width_cm: number;
    depth_cm: number;
    confidence: number;
  } | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    runInference();
  }, []);

  const runInference = async () => {
    setLoading(true);
    setError(null);
    try {
      const result = await inferDimensions(imageUri);
      setDimensions(result);
    } catch (err: any) {
      setError(err.message || 'Failed to estimate dimensions');
    } finally {
      setLoading(false);
    }
  };

  const handleRetake = () => {
    navigation.navigate('Camera', { mode: 'product' });
  };

  const handleConfirm = () => {
    if (!dimensions) return;
    navigation.navigate('MeasurementConfirmation', {
      imageUri,
      height_cm: dimensions.height_cm,
      width_cm: dimensions.width_cm,
      depth_cm: dimensions.depth_cm,
    });
  };

  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#4F46E5" />
        <Text style={styles.loadingText}>Analyzing product dimensions...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>{error}</Text>
        <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
          <Text style={styles.retakeButtonText}>Retake Photo</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Estimated Dimensions</Text>

      <View style={styles.dimensionsCard}>
        <View style={styles.dimensionRow}>
          <Text style={styles.label}>Height</Text>
          <Text style={styles.value}>{dimensions?.height_cm} cm</Text>
        </View>
        <View style={styles.dimensionRow}>
          <Text style={styles.label}>Width</Text>
          <Text style={styles.value}>{dimensions?.width_cm} cm</Text>
        </View>
        <View style={styles.dimensionRow}>
          <Text style={styles.label}>Depth</Text>
          <Text style={styles.value}>{dimensions?.depth_cm} cm</Text>
        </View>
      </View>

      <View style={styles.confidenceContainer}>
        <Text style={styles.confidenceLabel}>Confidence</Text>
        <Text style={[
          styles.confidenceValue,
          { color: (dimensions?.confidence ?? 0) >= 0.6 ? '#16A34A' : '#DC2626' }
        ]}>
          {Math.round((dimensions?.confidence ?? 0) * 100)}%
        </Text>
      </View>

      {(dimensions?.confidence ?? 0) < 0.6 && (
        <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
          <Text style={styles.retakeButtonText}>Retake Photo</Text>
        </TouchableOpacity>
      )}

      <TouchableOpacity style={styles.confirmButton} onPress={handleConfirm}>
        <Text style={styles.confirmButtonText}>Confirm Dimensions</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, backgroundColor: '#F9FAFB' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  title: { fontSize: 24, fontWeight: '700', marginBottom: 24, color: '#111827' },
  loadingText: { marginTop: 16, fontSize: 16, color: '#6B7280' },
  errorText: { fontSize: 16, color: '#DC2626', textAlign: 'center', marginBottom: 16 },
  dimensionsCard: { backgroundColor: '#FFFFFF', borderRadius: 12, padding: 20, marginBottom: 20 },
  dimensionRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F3F4F6' },
  label: { fontSize: 16, color: '#6B7280' },
  value: { fontSize: 18, fontWeight: '600', color: '#111827' },
  confidenceContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
  confidenceLabel: { fontSize: 16, color: '#6B7280' },
  confidenceValue: { fontSize: 20, fontWeight: '700' },
  retakeButton: { backgroundColor: '#FEF2F2', borderWidth: 1, borderColor: '#FCA5A5', borderRadius: 8, padding: 14, alignItems: 'center', marginBottom: 12 },
  retakeButtonText: { color: '#DC2626', fontSize: 16, fontWeight: '600' },
  confirmButton: { backgroundColor: '#4F46E5', borderRadius: 8, padding: 16, alignItems: 'center' },
  confirmButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

And here is the full MeasurementConfirmationScreen.tsx:

import React, { useState } from 'react';
import { View, Text, TextInput, Image, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { useDatabase } from '@nozbe/watermelondb/hooks';
import { useAuth } from '../context/AuthProvider';

export default function MeasurementConfirmationScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const database = useDatabase();
  const { session } = useAuth();

  const { imageUri, height_cm, width_cm, depth_cm } = route.params as {
    imageUri: string;
    height_cm: number;
    width_cm: number;
    depth_cm: number;
  };

  const [name, setName] = useState('');
  const [height, setHeight] = useState(String(height_cm));
  const [width, setWidth] = useState(String(width_cm));
  const [depth, setDepth] = useState(String(depth_cm));

  const handleConfirm = async () => {
    if (!name.trim()) {
      Alert.alert('Missing Name', 'Please enter a product name.');
      return;
    }

    try {
      const productsCollection = database.get('products');
      const newProduct = await database.write(async () => {
        return await productsCollection.create((product: any) => {
          product.name = name.trim();
          product.widthCm = parseFloat(width);
          product.heightCm = parseFloat(height);
          product.depthCm = parseFloat(depth);
          product.photoLocalPath = imageUri;
          product.placedBy = session?.user?.id ?? '';
          product._raw._status = 'created';
        });
      });

      navigation.navigate('PlacementRecommendation', { productId: newProduct.id });
    } catch (err) {
      Alert.alert('Error', 'Failed to save product. Please try again.');
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Confirm Measurements</Text>

      <Image source={{ uri: imageUri }} style={styles.thumbnail} />

      <Text style={styles.label}>Product Name</Text>
      <TextInput
        style={styles.input}
        value={name}
        onChangeText={setName}
        placeholder="Enter product name"
      />

      <Text style={styles.label}>Height (cm)</Text>
      <TextInput
        style={styles.input}
        value={height}
        onChangeText={setHeight}
        keyboardType="numeric"
      />

      <Text style={styles.label}>Width (cm)</Text>
      <TextInput
        style={styles.input}
        value={width}
        onChangeText={setWidth}
        keyboardType="numeric"
      />

      <Text style={styles.label}>Depth (cm)</Text>
      <TextInput
        style={styles.input}
        value={depth}
        onChangeText={setDepth}
        keyboardType="numeric"
      />

      <TouchableOpacity style={styles.confirmButton} onPress={handleConfirm}>
        <Text style={styles.confirmButtonText}>Save Product</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, backgroundColor: '#F9FAFB' },
  title: { fontSize: 24, fontWeight: '700', marginBottom: 16, color: '#111827' },
  thumbnail: { width: '100%', height: 160, borderRadius: 12, marginBottom: 20, backgroundColor: '#E5E7EB' },
  label: { fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 6, marginTop: 12 },
  input: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#D1D5DB', borderRadius: 8, padding: 12, fontSize: 16 },
  confirmButton: { backgroundColor: '#4F46E5', borderRadius: 8, padding: 16, alignItems: 'center', marginTop: 24 },
  confirmButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

Your measurement flow is complete. Workers can photograph a product, get AI-estimated dimensions on-device, adjust the results, and save everything locally. Next up, you will build the placement recommendation engine that suggests the best shelf slot for each product.

Build the Shelf Analysis Flow

Your product measurement flow is working. Workers can photograph a product, get AI-estimated dimensions, and save the result to the local database.

Now you need the same flow for shelves. When a worker photographs a shelf, the on-device AI detects individual slots, estimates their dimensions, and the worker confirms the data before saving. This gives the placement engine the inventory of available space it needs to make smart recommendations.

In this step, get ready to:
  • Build a ShelfScanScreen that sends shelf photos to on-device AI and displays detected slots.
  • Build a ShelfConfirmationScreen with an editable grid of slot dimensions.
  • Save shelf and slot records to WatermelonDB when the worker confirms.
Build the ShelfScanScreen

The ShelfScanScreen mirrors the ProductMeasureScreen you already built. It receives an image from the camera, sends it to the on-device AI via inferShelfSlots(), and displays how many slots were detected along with their dimensions.

  • Create a new file at src/screens/ShelfScanScreen.tsx and add the following code:
import React, { useState, useEffect } from 'react';
import {
  View,
  Text,
  StyleSheet,
  ActivityIndicator,
  TouchableOpacity,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { inferShelfSlots } from '../services/aiService';

// Shape of each slot returned by the AI
interface SlotData {
  width_cm: number;
  height_cm: number;
  depth_cm: number;
  is_occupied: boolean;
}

export default function ShelfScanScreen() {
  const navigation = useNavigation();
  const route = useRoute();

  // imageUri comes back from CameraScreen after capture
  const { imageUri } = route.params as { imageUri: string };

  const [loading, setLoading] = useState(true);
  const [slots, setSlots] = useState<SlotData[]>([]);
  const [error, setError] = useState<string | null>(null);

  // Run AI inference as soon as screen mounts
  useEffect(() => {
    analyzeShelf();
  }, []);

  const analyzeShelf = async () => {
    setLoading(true);
    setError(null);
    try {
      // Send the shelf photo to on-device Gemma 4 E2B
      const result = await inferShelfSlots(imageUri);
      setSlots(result.slots);
    } catch (err) {
      setError('Failed to analyze shelf. Please retake the photo.');
    } finally {
      setLoading(false);
    }
  };

  const handleRetake = () => {
    // Navigate back to camera in shelf mode
    navigation.navigate('Camera' as never, { mode: 'shelf' } as never);
  };

  const handleReviewSlots = () => {
    // Pass detected slots to the confirmation screen
    navigation.navigate(
      'ShelfConfirmation' as never,
      { imageUri, slots } as never
    );
  };

  // Show spinner while AI processes the image
  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#4F46E5" />
        <Text style={styles.loadingText}>Analyzing shelf structure...</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Shelf Analysis Results</Text>

      {error ? (
        <View style={styles.errorContainer}>
          <Text style={styles.errorText}>{error}</Text>
          <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
            <Text style={styles.retakeButtonText}>Retake Photo</Text>
          </TouchableOpacity>
        </View>
      ) : (
        <>
          <View style={styles.summaryCard}>
            <Text style={styles.slotCount}>
              {slots.length} slots detected
            </Text>
            {slots.map((slot, index) => (
              <Text key={index} style={styles.slotSummary}>
                Slot {index + 1}: {slot.width_cm}cm x {slot.height_cm}cm x{' '}
                {slot.depth_cm}cm
              </Text>
            ))}
          </View>

          <TouchableOpacity
            style={styles.reviewButton}
            onPress={handleReviewSlots}
          >
            <Text style={styles.reviewButtonText}>Review Slots</Text>
          </TouchableOpacity>

          <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
            <Text style={styles.retakeButtonText}>Retake Photo</Text>
          </TouchableOpacity>
        </>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#F9FAFB' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
  loadingText: { marginTop: 12, fontSize: 16, color: '#6B7280' },
  errorContainer: { alignItems: 'center', marginTop: 40 },
  errorText: {
    fontSize: 16,
    color: '#DC2626',
    marginBottom: 20,
    textAlign: 'center',
  },
  summaryCard: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    padding: 16,
    marginBottom: 24,
    elevation: 2,
  },
  slotCount: {
    fontSize: 20,
    fontWeight: '600',
    marginBottom: 12,
    color: '#1F2937',
  },
  slotSummary: { fontSize: 14, color: '#4B5563', marginBottom: 4 },
  reviewButton: {
    backgroundColor: '#4F46E5',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
    marginBottom: 12,
  },
  reviewButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
  retakeButton: {
    backgroundColor: '#E5E7EB',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
  },
  retakeButtonText: { color: '#374151', fontSize: 16, fontWeight: '600' },
});

What does this code do?

This screen follows the same pattern as ProductMeasureScreen. When it mounts, it immediately calls inferShelfSlots(imageUri) to send the captured shelf photo to the on-device Gemma 4 E2B model.

The AI returns a JSON array of detected slots with their estimated dimensions. The screen displays a count and a dimension summary for each slot. If the AI fails, the error state shows a Retake button so the worker can try again with a better photo.

  • Save src/screens/ShelfScanScreen.tsx.
  • Run your dev build on your physical device. Navigate to the shelf scan flow and take a photo of a shelf. You should see a loading spinner followed by a count of detected slots with their dimensions listed.

Seeing a blank screen or crash?

Make sure your navigation stack includes a route named ShelfScan that passes imageUri in the params. The CameraScreen must navigate here after capture when mode is 'shelf'.

If inference fails every time, confirm the AI model finished downloading by checking the ModelDownloadScreen progress indicator. The model must be fully initialized before inferShelfSlots() can work.

help me debug my ShelfScanScreen

Build the ShelfConfirmationScreen

The AI's slot detection is a starting point. Workers need to verify the count and adjust individual slot dimensions before anything gets saved. The ShelfConfirmationScreen renders an editable grid where each detected slot appears as a card with dimension fields the worker can correct.

  • Create a new file at src/screens/ShelfConfirmationScreen.tsx and add the following code:
import React, { useState } from 'react';
import {
  View,
  Text,
  StyleSheet,
  TextInput,
  FlatList,
  TouchableOpacity,
  Alert,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';

// Shape of each slot passed from ShelfScanScreen
interface SlotData {
  width_cm: number;
  height_cm: number;
  depth_cm: number;
}

export default function ShelfConfirmationScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const { imageUri, slots: initialSlots } = route.params as {
    imageUri: string;
    slots: SlotData[];
  };

  // Editable state for the shelf name and slot dimensions
  const [shelfName, setShelfName] = useState('');
  const [slots, setSlots] = useState<SlotData[]>(initialSlots);

  // Update a single dimension field for a specific slot
  const updateSlot = (
    index: number,
    field: keyof SlotData,
    value: string
  ) => {
    const updated = [...slots];
    updated[index] = { ...updated[index], [field]: parseFloat(value) || 0 };
    setSlots(updated);
  };

  // Add a new blank slot with default dimensions
  const addSlot = () => {
    setSlots([...slots, { width_cm: 30, height_cm: 30, depth_cm: 30 }]);
  };

  // Remove a slot by index
  const removeSlot = (index: number) => {
    setSlots(slots.filter((_, i) => i !== index));
  };

  // Placeholder confirm handler - database save added next
  const handleConfirm = async () => {
    if (!shelfName.trim()) {
      Alert.alert('Error', 'Please enter a shelf name.');
      return;
    }
    // Database write logic goes here
  };

  // Render each slot as an editable card
  const renderSlotCard = ({
    item,
    index,
  }: {
    item: SlotData;
    index: number;
  }) => (
    <View style={styles.slotCard}>
      <View style={styles.slotHeader}>
        <Text style={styles.slotLabel}>Slot {index + 1}</Text>
        <TouchableOpacity onPress={() => removeSlot(index)}>
          <Text style={styles.removeText}>Remove</Text>
        </TouchableOpacity>
      </View>
      <View style={styles.dimensionRow}>
        <View style={styles.dimensionField}>
          <Text style={styles.fieldLabel}>W (cm)</Text>
          <TextInput
            style={styles.input}
            value={String(item.width_cm)}
            onChangeText={(v) => updateSlot(index, 'width_cm', v)}
            keyboardType="numeric"
          />
        </View>
        <View style={styles.dimensionField}>
          <Text style={styles.fieldLabel}>H (cm)</Text>
          <TextInput
            style={styles.input}
            value={String(item.height_cm)}
            onChangeText={(v) => updateSlot(index, 'height_cm', v)}
            keyboardType="numeric"
          />
        </View>
        <View style={styles.dimensionField}>
          <Text style={styles.fieldLabel}>D (cm)</Text>
          <TextInput
            style={styles.input}
            value={String(item.depth_cm)}
            onChangeText={(v) => updateSlot(index, 'depth_cm', v)}
            keyboardType="numeric"
          />
        </View>
      </View>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Confirm Shelf Slots</Text>

      <TextInput
        style={styles.nameInput}
        placeholder="Shelf name (e.g. Shelf A-3)"
        value={shelfName}
        onChangeText={setShelfName}
      />

      <FlatList
        data={slots}
        renderItem={renderSlotCard}
        keyExtractor={(_, index) => `slot-${index}`}
        style={styles.list}
        ListFooterComponent={
          <TouchableOpacity style={styles.addButton} onPress={addSlot}>
            <Text style={styles.addButtonText}>+ Add Slot</Text>
          </TouchableOpacity>
        }
      />

      <TouchableOpacity style={styles.confirmButton} onPress={handleConfirm}>
        <Text style={styles.confirmButtonText}>
          Save Shelf ({slots.length} slots)
        </Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#F9FAFB' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
  nameInput: {
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    padding: 14,
    fontSize: 16,
    marginBottom: 16,
    borderWidth: 1,
    borderColor: '#D1D5DB',
  },
  list: { flex: 1, marginBottom: 16 },
  slotCard: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    padding: 16,
    marginBottom: 12,
    elevation: 2,
  },
  slotHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginBottom: 12,
  },
  slotLabel: { fontSize: 16, fontWeight: '600', color: '#1F2937' },
  removeText: { fontSize: 14, color: '#DC2626' },
  dimensionRow: { flexDirection: 'row', justifyContent: 'space-between' },
  dimensionField: { flex: 1, marginHorizontal: 4 },
  fieldLabel: { fontSize: 12, color: '#6B7280', marginBottom: 4 },
  input: {
    backgroundColor: '#F3F4F6',
    borderRadius: 6,
    padding: 10,
    fontSize: 16,
    textAlign: 'center',
  },
  addButton: {
    backgroundColor: '#E5E7EB',
    borderRadius: 8,
    padding: 14,
    alignItems: 'center',
    marginTop: 8,
  },
  addButtonText: { color: '#374151', fontSize: 16, fontWeight: '600' },
  confirmButton: {
    backgroundColor: '#4F46E5',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
  },
  confirmButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

What does this code do?

The component receives the slots array from ShelfScanScreen and renders each one as an editable card using a FlatList. Each card has three numeric inputs for width, height, and depth in centimeters.

Workers can add slots the AI missed or remove false positives. The handleConfirm function currently validates that the shelf has a name. You will add the database save logic in the next substep.

  • Save src/screens/ShelfConfirmationScreen.tsx.
  • Run the app on your device. After the ShelfScanScreen shows results, tap Review Slots. You should see editable slot cards with pre-filled dimensions and buttons to add or remove slots.

Slot cards not showing dimensions?

Check that ShelfScanScreen passes the slots array in navigation params. Each slot object must have width_cm, height_cm, and depth_cm as numeric values.

If the FlatList is empty, confirm the AI service inferShelfSlots() returns an object with a slots array. Log the response in ShelfScanScreen to verify the shape.

help me debug my ShelfConfirmationScreen

Save shelf and slot records to WatermelonDB

The UI is ready. Now you need to wire the confirm button to persist a Shelf record and one Slot record per detected slot into WatermelonDB. All database modifications must happen inside a database.write() transaction.

  • In src/screens/ShelfConfirmationScreen.tsx, add the database import at the top of the file. Find this line:
import { useNavigation, useRoute } from '@react-navigation/native';
  • Add this import directly below it:
import { database } from '../database';
  • Now find the handleConfirm function. It currently looks like this:
  // Placeholder confirm handler - database save added next
  const handleConfirm = async () => {
    if (!shelfName.trim()) {
      Alert.alert('Error', 'Please enter a shelf name.');
      return;
    }
    // Database write logic goes here
  };
  • Replace the entire handleConfirm function with this version that writes to the database:
  const handleConfirm = async () => {
    if (!shelfName.trim()) {
      Alert.alert('Error', 'Please enter a shelf name.');
      return;
    }

    try {
      await database.write(async () => {
        // Create the Shelf record linked to a zone
        const shelfCollection = database.get('shelves');
        const newShelf = await shelfCollection.create((shelf: any) => {
          shelf.name = shelfName;
          shelf.photoLocalPath = imageUri;
          shelf._raw._status = 'created';
        });

        // Create one Slot record per detected slot
        const slotCollection = database.get('slots');
        for (const slotData of slots) {
          await slotCollection.create((slot: any) => {
            slot.shelfId = newShelf.id;
            slot.widthCm = slotData.width_cm;
            slot.heightCm = slotData.height_cm;
            slot.depthCm = slotData.depth_cm;
            slot.isOccupied = false;
            slot._raw._status = 'created';
          });
        }
      });

      // Navigate to the shelf map after successful save
      navigation.navigate('ShelfMap' as never);
    } catch (err) {
      Alert.alert('Error', 'Failed to save shelf data. Please try again.');
    }
  };

What does this code do?

Everything inside database.write() runs as a single atomic transaction. If any part fails, nothing gets saved.

First it creates a Shelf record in the shelves collection with the worker's chosen name and the local photo path. Then it loops through the confirmed slots array and creates one Slot record per entry, linking each to the new shelf via shelfId. Every slot starts with isOccupied: false since the shelf was just scanned.

The _raw._status = 'created' flag marks these records as needing to be synced to Supabase once the device is online.

  • Save src/screens/ShelfConfirmationScreen.tsx.
  • Run the app on your device. Go through the full shelf flow: capture a photo, review slots on the ShelfScanScreen, tap Review Slots, adjust dimensions if needed, enter a shelf name, and tap Save Shelf. You should navigate to the ShelfMap screen without errors.

Getting a database write error?

Make sure your Shelf and Slot model classes have the correct @field decorators matching the column names in your schema. The field setters (shelf.name, slot.widthCm, etc.) must match the decorated property names in your Model files.

If you see the error "Not allowed to change record outside of create/update()", confirm that all field assignments happen inside the .create() builder function and that the database.write() wrapper is present.

help me fix my WatermelonDB write error

✔️ Awesome, I've got everything!

Great. Double check you have saved both ShelfScanScreen.tsx and ShelfConfirmationScreen.tsx.

ⓧ I'd like to double check the full code

Here is the complete ShelfScanScreen.tsx:

import React, { useState, useEffect } from 'react';
import {
  View,
  Text,
  StyleSheet,
  ActivityIndicator,
  TouchableOpacity,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { inferShelfSlots } from '../services/aiService';

interface SlotData {
  width_cm: number;
  height_cm: number;
  depth_cm: number;
  is_occupied: boolean;
}

export default function ShelfScanScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const { imageUri } = route.params as { imageUri: string };

  const [loading, setLoading] = useState(true);
  const [slots, setSlots] = useState<SlotData[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    analyzeShelf();
  }, []);

  const analyzeShelf = async () => {
    setLoading(true);
    setError(null);
    try {
      const result = await inferShelfSlots(imageUri);
      setSlots(result.slots);
    } catch (err) {
      setError('Failed to analyze shelf. Please retake the photo.');
    } finally {
      setLoading(false);
    }
  };

  const handleRetake = () => {
    navigation.navigate('Camera' as never, { mode: 'shelf' } as never);
  };

  const handleReviewSlots = () => {
    navigation.navigate(
      'ShelfConfirmation' as never,
      { imageUri, slots } as never
    );
  };

  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#4F46E5" />
        <Text style={styles.loadingText}>Analyzing shelf structure...</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Shelf Analysis Results</Text>

      {error ? (
        <View style={styles.errorContainer}>
          <Text style={styles.errorText}>{error}</Text>
          <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
            <Text style={styles.retakeButtonText}>Retake Photo</Text>
          </TouchableOpacity>
        </View>
      ) : (
        <>
          <View style={styles.summaryCard}>
            <Text style={styles.slotCount}>
              {slots.length} slots detected
            </Text>
            {slots.map((slot, index) => (
              <Text key={index} style={styles.slotSummary}>
                Slot {index + 1}: {slot.width_cm}cm x {slot.height_cm}cm x{' '}
                {slot.depth_cm}cm
              </Text>
            ))}
          </View>

          <TouchableOpacity
            style={styles.reviewButton}
            onPress={handleReviewSlots}
          >
            <Text style={styles.reviewButtonText}>Review Slots</Text>
          </TouchableOpacity>

          <TouchableOpacity style={styles.retakeButton} onPress={handleRetake}>
            <Text style={styles.retakeButtonText}>Retake Photo</Text>
          </TouchableOpacity>
        </>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#F9FAFB' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
  loadingText: { marginTop: 12, fontSize: 16, color: '#6B7280' },
  errorContainer: { alignItems: 'center', marginTop: 40 },
  errorText: {
    fontSize: 16,
    color: '#DC2626',
    marginBottom: 20,
    textAlign: 'center',
  },
  summaryCard: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    padding: 16,
    marginBottom: 24,
    elevation: 2,
  },
  slotCount: {
    fontSize: 20,
    fontWeight: '600',
    marginBottom: 12,
    color: '#1F2937',
  },
  slotSummary: { fontSize: 14, color: '#4B5563', marginBottom: 4 },
  reviewButton: {
    backgroundColor: '#4F46E5',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
    marginBottom: 12,
  },
  reviewButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
  retakeButton: {
    backgroundColor: '#E5E7EB',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
  },
  retakeButtonText: { color: '#374151', fontSize: 16, fontWeight: '600' },
});

And here is the complete ShelfConfirmationScreen.tsx:

import React, { useState } from 'react';
import {
  View,
  Text,
  StyleSheet,
  TextInput,
  FlatList,
  TouchableOpacity,
  Alert,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { database } from '../database';

interface SlotData {
  width_cm: number;
  height_cm: number;
  depth_cm: number;
}

export default function ShelfConfirmationScreen() {
  const navigation = useNavigation();
  const route = useRoute();
  const { imageUri, slots: initialSlots } = route.params as {
    imageUri: string;
    slots: SlotData[];
  };

  const [shelfName, setShelfName] = useState('');
  const [slots, setSlots] = useState<SlotData[]>(initialSlots);

  const updateSlot = (
    index: number,
    field: keyof SlotData,
    value: string
  ) => {
    const updated = [...slots];
    updated[index] = { ...updated[index], [field]: parseFloat(value) || 0 };
    setSlots(updated);
  };

  const addSlot = () => {
    setSlots([...slots, { width_cm: 30, height_cm: 30, depth_cm: 30 }]);
  };

  const removeSlot = (index: number) => {
    setSlots(slots.filter((_, i) => i !== index));
  };

  const handleConfirm = async () => {
    if (!shelfName.trim()) {
      Alert.alert('Error', 'Please enter a shelf name.');
      return;
    }

    try {
      await database.write(async () => {
        // Create the Shelf record linked to a zone
        const shelfCollection = database.get('shelves');
        const newShelf = await shelfCollection.create((shelf: any) => {
          shelf.name = shelfName;
          shelf.photoLocalPath = imageUri;
          shelf._raw._status = 'created';
        });

        // Create one Slot record per detected slot
        const slotCollection = database.get('slots');
        for (const slotData of slots) {
          await slotCollection.create((slot: any) => {
            slot.shelfId = newShelf.id;
            slot.widthCm = slotData.width_cm;
            slot.heightCm = slotData.height_cm;
            slot.depthCm = slotData.depth_cm;
            slot.isOccupied = false;
            slot._raw._status = 'created';
          });
        }
      });

      navigation.navigate('ShelfMap' as never);
    } catch (err) {
      Alert.alert('Error', 'Failed to save shelf data. Please try again.');
    }
  };

  const renderSlotCard = ({
    item,
    index,
  }: {
    item: SlotData;
    index: number;
  }) => (
    <View style={styles.slotCard}>
      <View style={styles.slotHeader}>
        <Text style={styles.slotLabel}>Slot {index + 1}</Text>
        <TouchableOpacity onPress={() => removeSlot(index)}>
          <Text style={styles.removeText}>Remove</Text>
        </TouchableOpacity>
      </View>
      <View style={styles.dimensionRow}>
        <View style={styles.dimensionField}>
          <Text style={styles.fieldLabel}>W (cm)</Text>
          <TextInput
            style={styles.input}
            value={String(item.width_cm)}
            onChangeText={(v) => updateSlot(index, 'width_cm', v)}
            keyboardType="numeric"
          />
        </View>
        <View style={styles.dimensionField}>
          <Text style={styles.fieldLabel}>H (cm)</Text>
          <TextInput
            style={styles.input}
            value={String(item.height_cm)}
            onChangeText={(v) => updateSlot(index, 'height_cm', v)}
            keyboardType="numeric"
          />
        </View>
        <View style={styles.dimensionField}>
          <Text style={styles.fieldLabel}>D (cm)</Text>
          <TextInput
            style={styles.input}
            value={String(item.depth_cm)}
            onChangeText={(v) => updateSlot(index, 'depth_cm', v)}
            keyboardType="numeric"
          />
        </View>
      </View>
    </View>
  );

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Confirm Shelf Slots</Text>

      <TextInput
        style={styles.nameInput}
        placeholder="Shelf name (e.g. Shelf A-3)"
        value={shelfName}
        onChangeText={setShelfName}
      />

      <FlatList
        data={slots}
        renderItem={renderSlotCard}
        keyExtractor={(_, index) => `slot-${index}`}
        style={styles.list}
        ListFooterComponent={
          <TouchableOpacity style={styles.addButton} onPress={addSlot}>
            <Text style={styles.addButtonText}>+ Add Slot</Text>
          </TouchableOpacity>
        }
      />

      <TouchableOpacity style={styles.confirmButton} onPress={handleConfirm}>
        <Text style={styles.confirmButtonText}>
          Save Shelf ({slots.length} slots)
        </Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#F9FAFB' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
  nameInput: {
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    padding: 14,
    fontSize: 16,
    marginBottom: 16,
    borderWidth: 1,
    borderColor: '#D1D5DB',
  },
  list: { flex: 1, marginBottom: 16 },
  slotCard: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    padding: 16,
    marginBottom: 12,
    elevation: 2,
  },
  slotHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginBottom: 12,
  },
  slotLabel: { fontSize: 16, fontWeight: '600', color: '#1F2937' },
  removeText: { fontSize: 14, color: '#DC2626' },
  dimensionRow: { flexDirection: 'row', justifyContent: 'space-between' },
  dimensionField: { flex: 1, marginHorizontal: 4 },
  fieldLabel: { fontSize: 12, color: '#6B7280', marginBottom: 4 },
  input: {
    backgroundColor: '#F3F4F6',
    borderRadius: 6,
    padding: 10,
    fontSize: 16,
    textAlign: 'center',
  },
  addButton: {
    backgroundColor: '#E5E7EB',
    borderRadius: 8,
    padding: 14,
    alignItems: 'center',
    marginTop: 8,
  },
  addButtonText: { color: '#374151', fontSize: 16, fontWeight: '600' },
  confirmButton: {
    backgroundColor: '#4F46E5',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
  },
  confirmButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

Your shelf analysis pipeline is complete. Workers can now photograph shelves, let the on-device AI detect available slots, adjust the results, and persist everything locally. Next up, you will build the placement recommendation engine that matches products to the best available slot.

Build the Placement Recommendation Engine

Your app can now measure products and scan shelves. The next challenge is connecting those two data sets so the app can recommend where to place a product.

In this step, you will build a recommendation engine that queries all empty slots, sends the product dimensions and slot dimensions to the on-device AI, and displays ranked placement options with clearance indicators.

In this step, get ready to:
  • Create a placement recommendation prompt and add it to the AI service.
  • Build the PlacementRecommendationScreen that queries empty slots and displays AI-ranked suggestions with clearance indicators.
  • Implement the Confirm Placement action that updates the database and logs activity.
Add the placement recommendation prompt

Unlike dimension estimation and shelf scanning, placement recommendations only need text input. The AI compares product dimensions against available slot dimensions to rank the best fit.

  • Open src/services/aiService.ts and add the PLACEMENT_RECOMMENDATION_PROMPT constant below your existing prompts:
// Prompt template for placement recommendations (text-only, no image)
export const PLACEMENT_RECOMMENDATION_PROMPT = `You are a warehouse placement assistant.

Given the following product dimensions (in cm):
{product_dimensions}

And the following available slots with their dimensions (in cm):
{available_slots}

Recommend the best slot for this product. The product must fit with at least 2cm clearance on each side (width, height, depth).

Return ONLY JSON in this exact format:
{
  "top_slot": {
    "slot_id": "<id of best slot>",
    "reason": "<brief explanation>",
    "clearance_h": <cm clearance height>,
    "clearance_w": <cm clearance width>,
    "clearance_d": <cm clearance depth>
  },
  "alternatives": [
    {
      "slot_id": "<id>",
      "reason": "<brief explanation>",
      "clearance_h": <cm>,
      "clearance_w": <cm>,
      "clearance_d": <cm>
    }
  ]
}

Return the top 1 recommendation and up to 2 alternatives. If no slots fit, return {"top_slot": null, "alternatives": []}.`;

What does this prompt do?

The prompt accepts two template variables: {product_dimensions} and {available_slots}. You will fill these with JSON data at runtime before sending to the AI.

The 2cm clearance rule ensures products are not jammed into slots. The AI calculates remaining space on each axis and returns it so you can visualize the fit.

  • Save src/services/aiService.ts.
Build the PlacementRecommendationScreen

This screen receives a product ID, loads its dimensions, queries all empty slots from WatermelonDB, and sends a text-only prompt to the on-device AI for ranked recommendations.

  • Create a new file at src/screens/PlacementRecommendationScreen.tsx and add the imports and type definitions:
import React, { useEffect, useState, useContext } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { database } from '../database';
import { Product } from '../database/models/Product';
import { Slot } from '../database/models/Slot';
import { Q } from '@nozbe/watermelondb';
import AICore from 'react-native-ai-core';
import { PLACEMENT_RECOMMENDATION_PROMPT } from '../services/aiService';
import { AuthContext } from '../context/AuthProvider';

// Shape of the AI response after JSON.parse
interface SlotRecommendation {
  slot_id: string;
  reason: string;
  clearance_h: number;
  clearance_w: number;
  clearance_d: number;
}

interface RecommendationResult {
  top_slot: SlotRecommendation | null;
  alternatives: SlotRecommendation[];
}

What does this code do?

The imports bring in WatermelonDB for local queries, the AICore module for on-device inference, and the AuthContext so you can identify the current worker when logging activity.

The RecommendationResult interface mirrors the JSON structure the prompt asks the AI to return. This gives you type safety when accessing the parsed response.

  • Below the type definitions, add the main component function that loads data and calls the AI:
export default function PlacementRecommendationScreen() {
  const route = useRoute<any>();
  const navigation = useNavigation<any>();
  const { session } = useContext(AuthContext);
  const { productId } = route.params;

  const [product, setProduct] = useState<Product | null>(null);
  const [recommendation, setRecommendation] = useState<RecommendationResult | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    loadAndRecommend();
  }, []);

  async function loadAndRecommend() {
    try {
      setLoading(true);
      setError(null);

      // Load the product from WatermelonDB
      const productRecord = await database
        .get<Product>('products')
        .find(productId);
      setProduct(productRecord);

      // Query all empty slots
      const emptySlots = await database
        .get<Slot>('slots')
        .query(Q.where('is_occupied', false))
        .fetch();

      if (emptySlots.length === 0) {
        setError('No empty slots available. Scan a shelf first.');
        setLoading(false);
        return;
      }

      // Format product dimensions as JSON string
      const productDimensions = JSON.stringify({
        height_cm: productRecord.height_cm,
        width_cm: productRecord.width_cm,
        depth_cm: productRecord.depth_cm,
      });

      // Format available slots as JSON string
      const slotsJson = JSON.stringify(
        emptySlots.map((slot) => ({
          slot_id: slot.id,
          height_cm: slot.height_cm,
          width_cm: slot.width_cm,
          depth_cm: slot.depth_cm,
        }))
      );

      // Fill the prompt template with actual data
      const filledPrompt = PLACEMENT_RECOMMENDATION_PROMPT
        .replace('{product_dimensions}', productDimensions)
        .replace('{available_slots}', slotsJson);

      // Call on-device AI (text-only, no image needed)
      const response = await AICore.generateResponse(filledPrompt);

      // Parse the JSON response
      const parsed: RecommendationResult = JSON.parse(response);
      setRecommendation(parsed);
    } catch (err: any) {
      setError(err.message || 'Failed to generate recommendation.');
    } finally {
      setLoading(false);
    }
  }

Why text-only inference here?

Placement recommendations do not need an image. The AI already has structured dimension data from previous scans. Sending a text-only prompt with AICore.generateResponse() is faster and uses less memory than the vision endpoint.

The Q.where('is_occupied', false) query leverages WatermelonDB's SQLite backend to efficiently find all empty slots without loading the entire dataset.

Display AI recommendations with clearance indicators

The user needs to see the top recommendation clearly, with visual bars indicating how much clearance exists on each axis. Two alternatives are shown below in case the top pick is not ideal.

  • Below the loadAndRecommend function, add the clearance bar helper and the render method:
  // Visual clearance bar component
  function ClearanceBar({ label, value }: { label: string; value: number }) {
    // Green if >= 2cm clearance, yellow if tight, red if < 0
    const color = value >= 2 ? '#4CAF50' : value >= 0 ? '#FFC107' : '#F44336';
    const barWidth = Math.max(Math.min(value * 10, 100), 5);

    return (
      <View style={styles.clearanceRow}>
        <Text style={styles.clearanceLabel}>{label}</Text>
        <View style={[styles.clearanceBar, { width: barWidth, backgroundColor: color }]} />
        <Text style={styles.clearanceValue}>{value}cm</Text>
      </View>
    );
  }

  // Render a single recommendation card
  function RecommendationCard({
    slot,
    isPrimary,
  }: {
    slot: SlotRecommendation;
    isPrimary: boolean;
  }) {
    return (
      <View style={[styles.card, isPrimary && styles.primaryCard]}>
        {isPrimary && <Text style={styles.badge}>Best Match</Text>}
        <Text style={styles.slotId}>Slot: {slot.slot_id}</Text>
        <Text style={styles.reason}>{slot.reason}</Text>
        <View style={styles.clearanceContainer}>
          <ClearanceBar label="H" value={slot.clearance_h} />
          <ClearanceBar label="W" value={slot.clearance_w} />
          <ClearanceBar label="D" value={slot.clearance_d} />
        </View>
        {isPrimary && (
          <TouchableOpacity
            style={styles.confirmButton}
            onPress={() => confirmPlacement(slot.slot_id)}
          >
            <Text style={styles.confirmButtonText}>Confirm Placement</Text>
          </TouchableOpacity>
        )}
      </View>
    );
  }

What do the clearance bars show?

Each bar represents the remaining space between the product and the slot wall on one axis. Green means healthy clearance (2cm or more). Yellow means it fits but is tight. Red means the product exceeds the slot dimension on that axis.

The Confirm Placement button only appears on the primary (top) recommendation card. Alternative slots are shown for comparison but require the worker to explicitly select them.

Implement the Confirm Placement action

When the worker taps Confirm Placement, the app needs to update the slot, link the product, and log the activity. All three writes happen inside a single database.write() transaction to ensure consistency.

  • Below the RecommendationCard component, add the confirmPlacement function:
  async function confirmPlacement(slotId: string) {
    try {
      await database.write(async () => {
        // Mark the slot as occupied
        const slot = await database.get<Slot>('slots').find(slotId);
        await slot.update((s) => {
          s.is_occupied = true;
        });

        // Link the product to this slot
        const productRecord = await database.get<Product>('products').find(productId);
        await productRecord.update((p) => {
          p.slot_id = slotId;
        });

        // Log the activity
        await database.get('activity_logs').create((log: any) => {
          log.worker_id = session?.user?.id || 'unknown';
          log.product_id = productId;
          log.slot_id = slotId;
          log.action = 'placed';
          log.timestamp = Date.now();
          log._status = 'created';
        });
      });

      // Navigate to ShelfMapScreen on success
      navigation.navigate('ShelfMap');
    } catch (err: any) {
      setError(err.message || 'Failed to confirm placement.');
    }
  }

Why a single database.write() transaction?

Wrapping all three operations (slot update, product link, activity log) in one database.write() call guarantees atomicity. If any operation fails, none of them persist. This prevents orphaned states like a slot marked occupied but no product linked to it.

The _status: 'created' field tells the WatermelonDB sync engine that this record needs to be pushed to Supabase on the next sync cycle.

  • Finally, add the JSX return and styles at the bottom of the component:
  // Main render
  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#6200ee" />
        <Text style={styles.loadingText}>Analyzing placement options...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>{error}</Text>
        <TouchableOpacity style={styles.retryButton} onPress={loadAndRecommend}>
          <Text style={styles.retryButtonText}>Retry</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <ScrollView style={styles.container}>
      {product && (
        <View style={styles.productInfo}>
          <Text style={styles.sectionTitle}>Product</Text>
          <Text style={styles.productName}>{product.name}</Text>
          <Text style={styles.dimensions}>
            {product.height_cm}H x {product.width_cm}W x {product.depth_cm}D cm
          </Text>
        </View>
      )}

      {recommendation?.top_slot && (
        <>
          <Text style={styles.sectionTitle}>Top Recommendation</Text>
          <RecommendationCard slot={recommendation.top_slot} isPrimary={true} />
        </>
      )}

      {recommendation?.alternatives && recommendation.alternatives.length > 0 && (
        <>
          <Text style={styles.sectionTitle}>Alternatives</Text>
          {recommendation.alternatives.map((alt, index) => (
            <RecommendationCard key={index} slot={alt} isPrimary={false} />
          ))}
        </>
      )}

      {recommendation?.top_slot === null && (
        <View style={styles.centered}>
          <Text style={styles.errorText}>No slots fit this product with 2cm clearance.</Text>
        </View>
      )}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, backgroundColor: '#f5f5f5' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  loadingText: { marginTop: 12, fontSize: 16, color: '#666' },
  errorText: { fontSize: 16, color: '#F44336', textAlign: 'center' },
  retryButton: { marginTop: 16, padding: 12, backgroundColor: '#6200ee', borderRadius: 8 },
  retryButtonText: { color: '#fff', fontWeight: 'bold' },
  productInfo: { backgroundColor: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 },
  sectionTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 8, color: '#333' },
  productName: { fontSize: 16, color: '#555' },
  dimensions: { fontSize: 14, color: '#888', marginTop: 4 },
  card: { backgroundColor: '#fff', padding: 16, borderRadius: 8, marginBottom: 12 },
  primaryCard: { borderWidth: 2, borderColor: '#4CAF50' },
  badge: { fontSize: 12, fontWeight: 'bold', color: '#4CAF50', marginBottom: 8 },
  slotId: { fontSize: 16, fontWeight: '600', color: '#333' },
  reason: { fontSize: 14, color: '#666', marginTop: 4, marginBottom: 12 },
  clearanceContainer: { marginBottom: 12 },
  clearanceRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
  clearanceLabel: { width: 20, fontSize: 14, fontWeight: 'bold', color: '#555' },
  clearanceBar: { height: 12, borderRadius: 4, marginHorizontal: 8 },
  clearanceValue: { fontSize: 12, color: '#888' },
  confirmButton: { backgroundColor: '#4CAF50', padding: 14, borderRadius: 8, alignItems: 'center' },
  confirmButtonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' },
});

What does the render logic handle?

The component shows a loading spinner while the AI processes, an error state with a retry button if something fails, and the full recommendation layout on success. The product dimensions are displayed at the top so the worker can cross-reference them with the slot clearance bars below.

  • Save src/screens/PlacementRecommendationScreen.tsx.
  • Run your app on the physical device. Navigate to a product, trigger the placement recommendation flow, and verify the screen loads with a recommendation card showing clearance bars.

Not seeing any recommendations?

Make sure you have at least one product saved with dimensions and at least one shelf scanned with empty slots. The query filters for is_occupied = false, so all slots must have that field set correctly.

If you see a JSON parse error, the AI response may contain extra text around the JSON. Check the raw response in a console.log and strip any markdown fences or preamble before parsing.

help me debug why PlacementRecommendationScreen is not showing AI results

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete PlacementRecommendationScreen.tsx for comparison:

import React, { useEffect, useState, useContext } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { database } from '../database';
import { Product } from '../database/models/Product';
import { Slot } from '../database/models/Slot';
import { Q } from '@nozbe/watermelondb';
import AICore from 'react-native-ai-core';
import { PLACEMENT_RECOMMENDATION_PROMPT } from '../services/aiService';
import { AuthContext } from '../context/AuthProvider';

// Shape of the AI response after JSON.parse
interface SlotRecommendation {
  slot_id: string;
  reason: string;
  clearance_h: number;
  clearance_w: number;
  clearance_d: number;
}

interface RecommendationResult {
  top_slot: SlotRecommendation | null;
  alternatives: SlotRecommendation[];
}

export default function PlacementRecommendationScreen() {
  const route = useRoute<any>();
  const navigation = useNavigation<any>();
  const { session } = useContext(AuthContext);
  const { productId } = route.params;

  const [product, setProduct] = useState<Product | null>(null);
  const [recommendation, setRecommendation] = useState<RecommendationResult | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    loadAndRecommend();
  }, []);

  async function loadAndRecommend() {
    try {
      setLoading(true);
      setError(null);

      // Load the product from WatermelonDB
      const productRecord = await database
        .get<Product>('products')
        .find(productId);
      setProduct(productRecord);

      // Query all empty slots
      const emptySlots = await database
        .get<Slot>('slots')
        .query(Q.where('is_occupied', false))
        .fetch();

      if (emptySlots.length === 0) {
        setError('No empty slots available. Scan a shelf first.');
        setLoading(false);
        return;
      }

      // Format product dimensions as JSON string
      const productDimensions = JSON.stringify({
        height_cm: productRecord.height_cm,
        width_cm: productRecord.width_cm,
        depth_cm: productRecord.depth_cm,
      });

      // Format available slots as JSON string
      const slotsJson = JSON.stringify(
        emptySlots.map((slot) => ({
          slot_id: slot.id,
          height_cm: slot.height_cm,
          width_cm: slot.width_cm,
          depth_cm: slot.depth_cm,
        }))
      );

      // Fill the prompt template with actual data
      const filledPrompt = PLACEMENT_RECOMMENDATION_PROMPT
        .replace('{product_dimensions}', productDimensions)
        .replace('{available_slots}', slotsJson);

      // Call on-device AI (text-only, no image needed)
      const response = await AICore.generateResponse(filledPrompt);

      // Parse the JSON response
      const parsed: RecommendationResult = JSON.parse(response);
      setRecommendation(parsed);
    } catch (err: any) {
      setError(err.message || 'Failed to generate recommendation.');
    } finally {
      setLoading(false);
    }
  }

  // Visual clearance bar component
  function ClearanceBar({ label, value }: { label: string; value: number }) {
    // Green if >= 2cm clearance, yellow if tight, red if < 0
    const color = value >= 2 ? '#4CAF50' : value >= 0 ? '#FFC107' : '#F44336';
    const barWidth = Math.max(Math.min(value * 10, 100), 5);

    return (
      <View style={styles.clearanceRow}>
        <Text style={styles.clearanceLabel}>{label}</Text>
        <View style={[styles.clearanceBar, { width: barWidth, backgroundColor: color }]} />
        <Text style={styles.clearanceValue}>{value}cm</Text>
      </View>
    );
  }

  // Render a single recommendation card
  function RecommendationCard({
    slot,
    isPrimary,
  }: {
    slot: SlotRecommendation;
    isPrimary: boolean;
  }) {
    return (
      <View style={[styles.card, isPrimary && styles.primaryCard]}>
        {isPrimary && <Text style={styles.badge}>Best Match</Text>}
        <Text style={styles.slotId}>Slot: {slot.slot_id}</Text>
        <Text style={styles.reason}>{slot.reason}</Text>
        <View style={styles.clearanceContainer}>
          <ClearanceBar label="H" value={slot.clearance_h} />
          <ClearanceBar label="W" value={slot.clearance_w} />
          <ClearanceBar label="D" value={slot.clearance_d} />
        </View>
        {isPrimary && (
          <TouchableOpacity
            style={styles.confirmButton}
            onPress={() => confirmPlacement(slot.slot_id)}
          >
            <Text style={styles.confirmButtonText}>Confirm Placement</Text>
          </TouchableOpacity>
        )}
      </View>
    );
  }

  async function confirmPlacement(slotId: string) {
    try {
      await database.write(async () => {
        // Mark the slot as occupied
        const slot = await database.get<Slot>('slots').find(slotId);
        await slot.update((s) => {
          s.is_occupied = true;
        });

        // Link the product to this slot
        const productRecord = await database.get<Product>('products').find(productId);
        await productRecord.update((p) => {
          p.slot_id = slotId;
        });

        // Log the activity
        await database.get('activity_logs').create((log: any) => {
          log.worker_id = session?.user?.id || 'unknown';
          log.product_id = productId;
          log.slot_id = slotId;
          log.action = 'placed';
          log.timestamp = Date.now();
          log._status = 'created';
        });
      });

      // Navigate to ShelfMapScreen on success
      navigation.navigate('ShelfMap');
    } catch (err: any) {
      setError(err.message || 'Failed to confirm placement.');
    }
  }

  // Main render
  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#6200ee" />
        <Text style={styles.loadingText}>Analyzing placement options...</Text>
      </View>
    );
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>{error}</Text>
        <TouchableOpacity style={styles.retryButton} onPress={loadAndRecommend}>
          <Text style={styles.retryButtonText}>Retry</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <ScrollView style={styles.container}>
      {product && (
        <View style={styles.productInfo}>
          <Text style={styles.sectionTitle}>Product</Text>
          <Text style={styles.productName}>{product.name}</Text>
          <Text style={styles.dimensions}>
            {product.height_cm}H x {product.width_cm}W x {product.depth_cm}D cm
          </Text>
        </View>
      )}

      {recommendation?.top_slot && (
        <>
          <Text style={styles.sectionTitle}>Top Recommendation</Text>
          <RecommendationCard slot={recommendation.top_slot} isPrimary={true} />
        </>
      )}

      {recommendation?.alternatives && recommendation.alternatives.length > 0 && (
        <>
          <Text style={styles.sectionTitle}>Alternatives</Text>
          {recommendation.alternatives.map((alt, index) => (
            <RecommendationCard key={index} slot={alt} isPrimary={false} />
          ))}
        </>
      )}

      {recommendation?.top_slot === null && (
        <View style={styles.centered}>
          <Text style={styles.errorText}>No slots fit this product with 2cm clearance.</Text>
        </View>
      )}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, backgroundColor: '#f5f5f5' },
  centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  loadingText: { marginTop: 12, fontSize: 16, color: '#666' },
  errorText: { fontSize: 16, color: '#F44336', textAlign: 'center' },
  retryButton: { marginTop: 16, padding: 12, backgroundColor: '#6200ee', borderRadius: 8 },
  retryButtonText: { color: '#fff', fontWeight: 'bold' },
  productInfo: { backgroundColor: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 },
  sectionTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 8, color: '#333' },
  productName: { fontSize: 16, color: '#555' },
  dimensions: { fontSize: 14, color: '#888', marginTop: 4 },
  card: { backgroundColor: '#fff', padding: 16, borderRadius: 8, marginBottom: 12 },
  primaryCard: { borderWidth: 2, borderColor: '#4CAF50' },
  badge: { fontSize: 12, fontWeight: 'bold', color: '#4CAF50', marginBottom: 8 },
  slotId: { fontSize: 16, fontWeight: '600', color: '#333' },
  reason: { fontSize: 14, color: '#666', marginTop: 4, marginBottom: 12 },
  clearanceContainer: { marginBottom: 12 },
  clearanceRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
  clearanceLabel: { width: 20, fontSize: 14, fontWeight: 'bold', color: '#555' },
  clearanceBar: { height: 12, borderRadius: 4, marginHorizontal: 8 },
  clearanceValue: { fontSize: 12, color: '#888' },
  confirmButton: { backgroundColor: '#4CAF50', padding: 14, borderRadius: 8, alignItems: 'center' },
  confirmButtonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' },
});

Your app now intelligently recommends shelf slots based on product dimensions. Next up, you will build the shelf map and product search screens so workers can visually browse slot availability and find placed products.

Build the Shelf Map and Product Search

Your placement flow is complete. Workers can photograph products, get AI-estimated dimensions, and confirm placements into shelf slots. The next challenge is visibility.

How does a worker know which slots are free at a glance? How do they find where a specific product was placed last week? In this step, you will build three screens that answer those questions: a color-coded shelf map, a product search, and an activity log.

In this step, get ready to:
  • Build a ShelfMapScreen that displays a color-coded grid of all slots for a given shelf.
  • Build a ProductSearchScreen that queries products by name and shows their full location path.
  • Build an ActivityLogScreen that shows a reactive feed of recent placements.
Build the shelf map screen

Workers need to see slot availability at a glance without tapping into each slot individually. The ShelfMapScreen renders every slot in a shelf as a colored card that updates reactively when placements change.

  • Create a new file at src/screens/ShelfMapScreen.tsx and add the following code:
import React, { useState } from 'react';
import { View, FlatList, TouchableOpacity, Modal, Text, StyleSheet } from 'react-native';
import { withObservables } from '@nozbe/watermelondb/react';
import { Q } from '@nozbe/watermelondb';
import Slot from '../database/models/Slot';
import Product from '../database/models/Product';

// Determine slot color based on occupancy and fit
const getSlotColor = (slot: Slot, product?: Product): string => {
  if (slot.isOccupied) return '#ef4444'; // red - occupied
  if (!product) return '#22c55e'; // green - empty, no product context

  // Yellow if product dimensions are within 10% of slot dimensions
  const heightFit = Math.abs(product.height - slot.height) / slot.height;
  const widthFit = Math.abs(product.width - slot.width) / slot.width;
  const depthFit = Math.abs(product.depth - slot.depth) / slot.depth;

  if (heightFit <= 0.1 && widthFit <= 0.1 && depthFit <= 0.1) {
    return '#eab308'; // yellow - partially fitting
  }

  return '#22c55e'; // green - empty
};

interface SlotCardProps {
  slot: Slot;
  onPress: (slot: Slot) => void;
}

// Individual slot card in the grid
const SlotCard = ({ slot, onPress }: SlotCardProps) => {
  const color = slot.isOccupied ? '#ef4444' : '#22c55e';

  return (
    <TouchableOpacity
      style={[styles.slotCard, { backgroundColor: color }]}
      onPress={() => onPress(slot)}
    >
      <Text style={styles.slotLabel}>{slot.label}</Text>
    </TouchableOpacity>
  );
};

interface ShelfMapProps {
  slots: Slot[];
  shelfId: string;
}

// Main shelf map component displaying all slots in a grid
const ShelfMapScreen = ({ slots }: ShelfMapProps) => {
  const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);
  const [modalVisible, setModalVisible] = useState(false);

  const handleSlotPress = (slot: Slot) => {
    setSelectedSlot(slot);
    setModalVisible(true);
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={slots}
        keyExtractor={(item) => item.id}
        numColumns={3}
        renderItem={({ item }) => (
          <SlotCard slot={item} onPress={handleSlotPress} />
        )}
      />

      {/* Modal showing slot details when tapped */}
      <Modal visible={modalVisible} transparent animationType="slide">
        <View style={styles.modalOverlay}>
          <View style={styles.modalContent}>
            {selectedSlot && (
              <>
                <Text style={styles.modalTitle}>Slot: {selectedSlot.label}</Text>
                <Text>Height: {selectedSlot.height} cm</Text>
                <Text>Width: {selectedSlot.width} cm</Text>
                <Text>Depth: {selectedSlot.depth} cm</Text>
                <Text>
                  Status: {selectedSlot.isOccupied ? 'Occupied' : 'Available'}
                </Text>
              </>
            )}
            <TouchableOpacity
              style={styles.closeButton}
              onPress={() => setModalVisible(false)}
            >
              <Text style={styles.closeButtonText}>Close</Text>
            </TouchableOpacity>
          </View>
        </View>
      </Modal>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  slotCard: {
    flex: 1,
    margin: 4,
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
    justifyContent: 'center',
    minHeight: 80,
  },
  slotLabel: { color: '#fff', fontWeight: 'bold', fontSize: 14 },
  modalOverlay: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.5)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  modalContent: {
    backgroundColor: '#fff',
    padding: 24,
    borderRadius: 12,
    width: '80%',
  },
  modalTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 12 },
  closeButton: {
    marginTop: 16,
    padding: 12,
    backgroundColor: '#3b82f6',
    borderRadius: 8,
    alignItems: 'center',
  },
  closeButtonText: { color: '#fff', fontWeight: 'bold' },
});

// withObservables subscribes to slots filtered by shelf_id
const enhance = withObservables(['shelfId'], ({ shelfId, database }) => ({
  slots: database
    .get('slots')
    .query(Q.where('shelf_id', shelfId))
    .observe(),
}));

export default enhance(ShelfMapScreen);

What does this code do?

The withObservables HOC from WatermelonDB subscribes to the slots collection filtered by shelf_id. Any time a slot record changes (occupied, freed, dimensions updated), the component re-renders automatically.

Each slot card uses a simple color system: green means the slot is empty and available, red means it is occupied, and yellow means a product fits within 10% of the slot dimensions. Tapping a slot opens a modal showing its exact dimensions and occupancy status.

  • Save src/screens/ShelfMapScreen.tsx.
  • Build and run the app on your device. Navigate to a shelf you previously scanned. You should see a grid of colored slot cards.

Seeing a blank grid?

Make sure you have scanned at least one shelf with slots saved from the ShelfScanScreen. The query filters by shelf_id so it returns nothing if no slots exist for that shelf.

Also confirm the shelfId prop is being passed correctly from your navigation params.

help me debug why my ShelfMapScreen is showing no slots

Build the product search screen

Workers need to quickly locate any product across the entire warehouse. The ProductSearchScreen queries WatermelonDB by product name and resolves each result's full location path by traversing relations from product to slot to shelf to zone.

  • Create a new file at src/screens/ProductSearchScreen.tsx and add the following code:
import React, { useState, useCallback } from 'react';
import { View, TextInput, FlatList, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { Q } from '@nozbe/watermelondb';
import { useDatabase } from '@nozbe/watermelondb/react';
import Product from '../database/models/Product';

interface ProductResult {
  product: Product;
  locationPath: string; // e.g. "Zone A > Shelf 3 > Slot 2"
}

// Resolves the full location path by traversing relations
const resolveLocation = async (product: Product): Promise<string> => {
  try {
    const slot = await product.slot.fetch();
    if (!slot) return 'Unplaced';

    const shelf = await slot.shelf.fetch();
    if (!shelf) return 'Unknown shelf';

    const zone = await shelf.zone.fetch();
    if (!zone) return `${shelf.name} > ${slot.label}`;

    return `${zone.name} > ${shelf.name} > ${slot.label}`;
  } catch {
    return 'Location unavailable';
  }
};

const ProductSearchScreen = ({ navigation }: any) => {
  const database = useDatabase();
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState<ProductResult[]>([]);

  // Query products by name using case-insensitive LIKE
  const handleSearch = useCallback(
    async (term: string) => {
      setSearchTerm(term);

      if (term.length < 2) {
        setResults([]);
        return;
      }

      // Sanitize input and query with LIKE for partial matching
      const sanitized = Q.sanitizeLikeString(term);
      const products = await database
        .get<Product>('products')
        .query(Q.where('name', Q.like(`%${sanitized}%`)))
        .fetch();

      // Resolve location path for each product
      const resolved = await Promise.all(
        products.map(async (product) => ({
          product,
          locationPath: await resolveLocation(product),
        }))
      );

      setResults(resolved);
    },
    [database]
  );

  // Navigate to ShelfMapScreen highlighting the selected slot
  const handleResultPress = (result: ProductResult) => {
    if (result.product.slotId) {
      navigation.navigate('ShelfMap', {
        shelfId: result.product.slotId,
      });
    }
  };

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.searchInput}
        placeholder="Search products by name..."
        value={searchTerm}
        onChangeText={handleSearch}
        autoCapitalize="none"
      />

      <FlatList
        data={results}
        keyExtractor={(item) => item.product.id}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.resultCard}
            onPress={() => handleResultPress(item)}
          >
            <Text style={styles.productName}>{item.product.name}</Text>
            <Text style={styles.locationPath}>{item.locationPath}</Text>
          </TouchableOpacity>
        )}
        ListEmptyComponent={
          searchTerm.length >= 2 ? (
            <Text style={styles.emptyText}>No products found.</Text>
          ) : null
        }
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  searchInput: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    marginBottom: 16,
  },
  resultCard: {
    padding: 16,
    backgroundColor: '#f9fafb',
    borderRadius: 8,
    marginBottom: 8,
    borderWidth: 1,
    borderColor: '#e5e7eb',
  },
  productName: { fontSize: 16, fontWeight: 'bold', marginBottom: 4 },
  locationPath: { fontSize: 14, color: '#6b7280' },
  emptyText: { textAlign: 'center', color: '#9ca3af', marginTop: 24 },
});

export default ProductSearchScreen;

What does this code do?

The search handler uses Q.like with Q.sanitizeLikeString for safe, case-insensitive partial matching against the product name column. This runs the query directly in SQLite for fast results.

Each result resolves its full location path by traversing the relation chain: Product has a slot relation, Slot has a shelf relation, and Shelf has a zone relation. The result displays as Zone > Shelf > Slot.

Tapping a result navigates to the ShelfMapScreen so the worker can see surrounding slot availability.

  • Save src/screens/ProductSearchScreen.tsx.
  • Rebuild the app and navigate to the search screen. Type a product name you previously saved. You should see the product with its Zone > Shelf > Slot location path.

Search returning no results?

Make sure you have at least one product saved in WatermelonDB from the ProductMeasureScreen flow. The search requires a minimum of 2 characters before querying.

If location paths show "Unplaced", the product has not been assigned to a slot yet through the PlacementRecommendationScreen.

help me debug product search in my warehouse app

Build the activity log screen

The activity log gives workers and supervisors a timeline of every placement. It uses withObservables so the list updates in real time as new placements are confirmed anywhere in the app.

  • Create a new file at src/screens/ActivityLogScreen.tsx and add the following code:
import React from 'react';
import { View, FlatList, Text, StyleSheet } from 'react-native';
import { withObservables } from '@nozbe/watermelondb/react';
import { Q } from '@nozbe/watermelondb';
import ActivityLog from '../database/models/ActivityLog';

// Format timestamp to readable date string
const formatTimestamp = (timestamp: number): string => {
  const date = new Date(timestamp);
  return date.toLocaleString();
};

interface LogEntryProps {
  entry: ActivityLog;
}

// Individual log entry row
const LogEntry = ({ entry }: LogEntryProps) => (
  <View style={styles.entryCard}>
    <View style={styles.entryHeader}>
      <Text style={styles.actionBadge}>{entry.action}</Text>
      <Text style={styles.timestamp}>{formatTimestamp(entry.timestamp)}</Text>
    </View>
    <Text style={styles.details}>
      Worker: {entry.workerId}
    </Text>
    <Text style={styles.details}>
      Product: {entry.productId} | Slot: {entry.slotId}
    </Text>
  </View>
);

interface ActivityLogScreenProps {
  activityLogs: ActivityLog[];
}

// Main activity log screen with reactive updates
const ActivityLogScreen = ({ activityLogs }: ActivityLogScreenProps) => (
  <View style={styles.container}>
    <Text style={styles.title}>Activity Log</Text>
    <FlatList
      data={activityLogs}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <LogEntry entry={item} />}
      ListEmptyComponent={
        <Text style={styles.emptyText}>No activity recorded yet.</Text>
      }
    />
  </View>
);

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  title: { fontSize: 20, fontWeight: 'bold', marginBottom: 16 },
  entryCard: {
    padding: 12,
    backgroundColor: '#f9fafb',
    borderRadius: 8,
    marginBottom: 8,
    borderWidth: 1,
    borderColor: '#e5e7eb',
  },
  entryHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginBottom: 8,
  },
  actionBadge: {
    backgroundColor: '#3b82f6',
    color: '#fff',
    paddingHorizontal: 8,
    paddingVertical: 2,
    borderRadius: 4,
    fontSize: 12,
    fontWeight: 'bold',
    overflow: 'hidden',
  },
  timestamp: { fontSize: 12, color: '#6b7280' },
  details: { fontSize: 14, color: '#374151', marginBottom: 2 },
  emptyText: { textAlign: 'center', color: '#9ca3af', marginTop: 24 },
});

// Subscribe to activity_log collection sorted by timestamp descending
const enhance = withObservables([], ({ database }) => ({
  activityLogs: database
    .get('activity_log')
    .query(Q.sortBy('timestamp', Q.desc))
    .observe(),
}));

export default enhance(ActivityLogScreen);

What does this code do?

The withObservables enhancement passes an empty trigger array ([]) because the query does not depend on any props. It subscribes once on mount and re-emits whenever the activity_log collection changes.

The query uses Q.sortBy('timestamp', Q.desc) to show the most recent placements first. Each entry displays the worker ID, product, slot, action type, and a human-readable timestamp.

  • Save src/screens/ActivityLogScreen.tsx.
  • Rebuild the app and navigate to the activity log. You should see entries for any placements you confirmed in earlier steps, with the most recent at the top.

Activity log showing empty?

The activity log populates from placement confirmations in the PlacementRecommendationScreen. Go back and confirm at least one placement to generate an entry.

If entries exist but the screen is blank, verify your ActivityLog model is registered in the database initialization at src/database/index.ts.

help me debug the activity log screen in my warehouse app

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for all three screens created in this step.

import React, { useState } from 'react';
import { View, FlatList, TouchableOpacity, Modal, Text, StyleSheet } from 'react-native';
import { withObservables } from '@nozbe/watermelondb/react';
import { Q } from '@nozbe/watermelondb';
import Slot from '../database/models/Slot';
import Product from '../database/models/Product';

const getSlotColor = (slot: Slot, product?: Product): string => {
  if (slot.isOccupied) return '#ef4444';
  if (!product) return '#22c55e';

  const heightFit = Math.abs(product.height - slot.height) / slot.height;
  const widthFit = Math.abs(product.width - slot.width) / slot.width;
  const depthFit = Math.abs(product.depth - slot.depth) / slot.depth;

  if (heightFit <= 0.1 && widthFit <= 0.1 && depthFit <= 0.1) {
    return '#eab308';
  }

  return '#22c55e';
};

interface SlotCardProps {
  slot: Slot;
  onPress: (slot: Slot) => void;
}

const SlotCard = ({ slot, onPress }: SlotCardProps) => {
  const color = slot.isOccupied ? '#ef4444' : '#22c55e';

  return (
    <TouchableOpacity
      style={[styles.slotCard, { backgroundColor: color }]}
      onPress={() => onPress(slot)}
    >
      <Text style={styles.slotLabel}>{slot.label}</Text>
    </TouchableOpacity>
  );
};

interface ShelfMapProps {
  slots: Slot[];
  shelfId: string;
}

const ShelfMapScreen = ({ slots }: ShelfMapProps) => {
  const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);
  const [modalVisible, setModalVisible] = useState(false);

  const handleSlotPress = (slot: Slot) => {
    setSelectedSlot(slot);
    setModalVisible(true);
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={slots}
        keyExtractor={(item) => item.id}
        numColumns={3}
        renderItem={({ item }) => (
          <SlotCard slot={item} onPress={handleSlotPress} />
        )}
      />

      <Modal visible={modalVisible} transparent animationType="slide">
        <View style={styles.modalOverlay}>
          <View style={styles.modalContent}>
            {selectedSlot && (
              <>
                <Text style={styles.modalTitle}>Slot: {selectedSlot.label}</Text>
                <Text>Height: {selectedSlot.height} cm</Text>
                <Text>Width: {selectedSlot.width} cm</Text>
                <Text>Depth: {selectedSlot.depth} cm</Text>
                <Text>
                  Status: {selectedSlot.isOccupied ? 'Occupied' : 'Available'}
                </Text>
              </>
            )}
            <TouchableOpacity
              style={styles.closeButton}
              onPress={() => setModalVisible(false)}
            >
              <Text style={styles.closeButtonText}>Close</Text>
            </TouchableOpacity>
          </View>
        </View>
      </Modal>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  slotCard: {
    flex: 1,
    margin: 4,
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
    justifyContent: 'center',
    minHeight: 80,
  },
  slotLabel: { color: '#fff', fontWeight: 'bold', fontSize: 14 },
  modalOverlay: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.5)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  modalContent: {
    backgroundColor: '#fff',
    padding: 24,
    borderRadius: 12,
    width: '80%',
  },
  modalTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 12 },
  closeButton: {
    marginTop: 16,
    padding: 12,
    backgroundColor: '#3b82f6',
    borderRadius: 8,
    alignItems: 'center',
  },
  closeButtonText: { color: '#fff', fontWeight: 'bold' },
});

const enhance = withObservables(['shelfId'], ({ shelfId, database }) => ({
  slots: database
    .get('slots')
    .query(Q.where('shelf_id', shelfId))
    .observe(),
}));

export default enhance(ShelfMapScreen);
import React, { useState, useCallback } from 'react';
import { View, TextInput, FlatList, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { Q } from '@nozbe/watermelondb';
import { useDatabase } from '@nozbe/watermelondb/react';
import Product from '../database/models/Product';

interface ProductResult {
  product: Product;
  locationPath: string;
}

const resolveLocation = async (product: Product): Promise<string> => {
  try {
    const slot = await product.slot.fetch();
    if (!slot) return 'Unplaced';

    const shelf = await slot.shelf.fetch();
    if (!shelf) return 'Unknown shelf';

    const zone = await shelf.zone.fetch();
    if (!zone) return `${shelf.name} > ${slot.label}`;

    return `${zone.name} > ${shelf.name} > ${slot.label}`;
  } catch {
    return 'Location unavailable';
  }
};

const ProductSearchScreen = ({ navigation }: any) => {
  const database = useDatabase();
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState<ProductResult[]>([]);

  const handleSearch = useCallback(
    async (term: string) => {
      setSearchTerm(term);

      if (term.length < 2) {
        setResults([]);
        return;
      }

      const sanitized = Q.sanitizeLikeString(term);
      const products = await database
        .get<Product>('products')
        .query(Q.where('name', Q.like(`%${sanitized}%`)))
        .fetch();

      const resolved = await Promise.all(
        products.map(async (product) => ({
          product,
          locationPath: await resolveLocation(product),
        }))
      );

      setResults(resolved);
    },
    [database]
  );

  const handleResultPress = (result: ProductResult) => {
    if (result.product.slotId) {
      navigation.navigate('ShelfMap', {
        shelfId: result.product.slotId,
      });
    }
  };

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.searchInput}
        placeholder="Search products by name..."
        value={searchTerm}
        onChangeText={handleSearch}
        autoCapitalize="none"
      />

      <FlatList
        data={results}
        keyExtractor={(item) => item.product.id}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.resultCard}
            onPress={() => handleResultPress(item)}
          >
            <Text style={styles.productName}>{item.product.name}</Text>
            <Text style={styles.locationPath}>{item.locationPath}</Text>
          </TouchableOpacity>
        )}
        ListEmptyComponent={
          searchTerm.length >= 2 ? (
            <Text style={styles.emptyText}>No products found.</Text>
          ) : null
        }
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  searchInput: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    marginBottom: 16,
  },
  resultCard: {
    padding: 16,
    backgroundColor: '#f9fafb',
    borderRadius: 8,
    marginBottom: 8,
    borderWidth: 1,
    borderColor: '#e5e7eb',
  },
  productName: { fontSize: 16, fontWeight: 'bold', marginBottom: 4 },
  locationPath: { fontSize: 14, color: '#6b7280' },
  emptyText: { textAlign: 'center', color: '#9ca3af', marginTop: 24 },
});

export default ProductSearchScreen;
import React from 'react';
import { View, FlatList, Text, StyleSheet } from 'react-native';
import { withObservables } from '@nozbe/watermelondb/react';
import { Q } from '@nozbe/watermelondb';
import ActivityLog from '../database/models/ActivityLog';

const formatTimestamp = (timestamp: number): string => {
  const date = new Date(timestamp);
  return date.toLocaleString();
};

interface LogEntryProps {
  entry: ActivityLog;
}

const LogEntry = ({ entry }: LogEntryProps) => (
  <View style={styles.entryCard}>
    <View style={styles.entryHeader}>
      <Text style={styles.actionBadge}>{entry.action}</Text>
      <Text style={styles.timestamp}>{formatTimestamp(entry.timestamp)}</Text>
    </View>
    <Text style={styles.details}>
      Worker: {entry.workerId}
    </Text>
    <Text style={styles.details}>
      Product: {entry.productId} | Slot: {entry.slotId}
    </Text>
  </View>
);

interface ActivityLogScreenProps {
  activityLogs: ActivityLog[];
}

const ActivityLogScreen = ({ activityLogs }: ActivityLogScreenProps) => (
  <View style={styles.container}>
    <Text style={styles.title}>Activity Log</Text>
    <FlatList
      data={activityLogs}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <LogEntry entry={item} />}
      ListEmptyComponent={
        <Text style={styles.emptyText}>No activity recorded yet.</Text>
      }
    />
  </View>
);

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  title: { fontSize: 20, fontWeight: 'bold', marginBottom: 16 },
  entryCard: {
    padding: 12,
    backgroundColor: '#f9fafb',
    borderRadius: 8,
    marginBottom: 8,
    borderWidth: 1,
    borderColor: '#e5e7eb',
  },
  entryHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginBottom: 8,
  },
  actionBadge: {
    backgroundColor: '#3b82f6',
    color: '#fff',
    paddingHorizontal: 8,
    paddingVertical: 2,
    borderRadius: 4,
    fontSize: 12,
    fontWeight: 'bold',
    overflow: 'hidden',
  },
  timestamp: { fontSize: 12, color: '#6b7280' },
  details: { fontSize: 14, color: '#374151', marginBottom: 2 },
  emptyText: { textAlign: 'center', color: '#9ca3af', marginTop: 24 },
});

const enhance = withObservables([], ({ database }) => ({
  activityLogs: database
    .get('activity_log')
    .query(Q.sortBy('timestamp', Q.desc))
    .observe(),
}));

export default enhance(ActivityLogScreen);

All three visibility screens are working. Workers can see slot availability at a glance, find any product by name, and review who placed what and where. Next up, you will build the offline sync engine to push all this local data to Supabase when connectivity returns.

Implement the Offline Sync Engine

Your warehouse app works entirely offline right now. Workers can photograph products, get AI dimension estimates, receive placement recommendations, and browse the shelf map. All of that data lives only on the device.

In this step, you will build the sync engine that bridges local and cloud. When the device regains connectivity, all local changes push to Supabase automatically. Changes from other workers pull down at the same time.

In this step, get ready to:
  • Implement the WatermelonDB sync engine with pull and push functions that talk to Supabase.
  • Add network detection that auto-triggers sync on reconnect.
  • Build a visual sync status indicator for the app header.
Install the network detection library

Before writing sync logic, you need a way to know whether the device is online. The @react-native-community/netinfo library exposes the device's connectivity state and fires events when it changes.

  • Install the NetInfo library by running this command in your project directory:
npx expo install @react-native-community/netinfo

What does this install?

NetInfo gives you a NetInfo.addEventListener function that fires a callback every time network connectivity changes. You will use this to trigger sync automatically when the device reconnects.

  • Confirm the install succeeded by checking that @react-native-community/netinfo appears in your package.json dependencies.

Install failed?

Make sure you are running the command inside your project folder (the one containing package.json). If you see a peer dependency warning, you can safely ignore it for Expo managed projects.

Help me troubleshoot my NetInfo installation

Build the sync service

The sync service is the bridge between your local WatermelonDB database and Supabase in the cloud. It uses WatermelonDB's built-in synchronize() function, which handles the heavy lifting of tracking which records changed locally.

You provide two async functions: pullChanges to fetch server updates, and pushChanges to send local changes up. The library handles ordering, deduplication, and status tracking internally.

  • Create a new file at src/services/syncService.ts and add the following imports and status management:
import { synchronize } from '@nozbe/watermelondb/sync';
import { Database } from '@nozbe/watermelondb';
import { SupabaseClient } from '@supabase/supabase-js';

// Sync status observable for UI consumption
export type SyncStatus = 'synced' | 'pending' | 'syncing';

let currentSyncStatus: SyncStatus = 'synced';
const listeners: Array<(status: SyncStatus) => void> = [];

// Allow components to subscribe to sync status changes
export function subscribeSyncStatus(listener: (status: SyncStatus) => void) {
  listeners.push(listener);
  // Immediately emit current status
  listener(currentSyncStatus);
  return () => {
    const index = listeners.indexOf(listener);
    if (index > -1) listeners.splice(index, 1);
  };
}

function setSyncStatus(status: SyncStatus) {
  currentSyncStatus = status;
  listeners.forEach((listener) => listener(status));
}

export function getSyncStatus(): SyncStatus {
  return currentSyncStatus;
}

What does this code do?

This sets up a lightweight pub/sub pattern for sync status. Components can call subscribeSyncStatus() to get notified whenever the status changes between synced, pending, and syncing.

The subscriber function returns an unsubscribe function, following the same pattern React's useEffect expects for cleanup.

  • Add the table list and the core sync function below the status management code in the same file:
// All tables that participate in sync
const SYNC_TABLES = [
  'warehouses',
  'zones',
  'shelves',
  'slots',
  'products',
  'activity_log',
];

// Guard against concurrent syncs
let isSyncing = false;

export async function sync(database: Database, supabase: SupabaseClient) {
  if (isSyncing) {
    console.log('Sync already in progress, skipping');
    return;
  }

  isSyncing = true;
  setSyncStatus('syncing');

  try {
    await synchronize({
      database,
      pullChanges: async ({ lastPulledAt }) => {
        // Fetch all records updated since last pull from each table
        const changes: Record<string, any> = {};
        const timestamp = Date.now();

        for (const table of SYNC_TABLES) {
          let query = supabase.from(table).select('*');

          // Only fetch records newer than last pull
          if (lastPulledAt) {
            query = query.gt('updated_at', new Date(lastPulledAt).toISOString());
          }

          const { data: records, error } = await query;
          if (error) throw new Error(error.message);

          // Separate into created vs updated based on created_at
          const createdRecords: any[] = [];
          const updatedRecords: any[] = [];

          (records || []).forEach((record: any) => {
            if (!lastPulledAt || new Date(record.created_at).getTime() > lastPulledAt) {
              createdRecords.push(record);
            } else {
              updatedRecords.push(record);
            }
          });

          // Fetch deleted record IDs (soft-delete pattern)
          let deletedIds: string[] = [];
          if (lastPulledAt) {
            const { data: deleted } = await supabase
              .from(table)
              .select('id')
              .eq('is_deleted', true)
              .gt('updated_at', new Date(lastPulledAt).toISOString());
            deletedIds = (deleted || []).map((r: any) => r.id);
          }

          changes[table] = {
            created: createdRecords,
            updated: updatedRecords,
            deleted: deletedIds,
          };
        }

        return { changes, timestamp };
      },
      pushChanges: async ({ changes }) => {
        // Push local creates, updates, and deletes to Supabase
        for (const table of SYNC_TABLES) {
          const tableChanges = changes[table];
          if (!tableChanges) continue;

          // Upsert created records (last-write-wins on conflict)
          if (tableChanges.created?.length) {
            const { error } = await supabase
              .from(table)
              .upsert(tableChanges.created, { onConflict: 'id' });
            if (error) throw new Error(error.message);
          }

          // Upsert updated records (last-write-wins via updated_at)
          if (tableChanges.updated?.length) {
            const { error } = await supabase
              .from(table)
              .upsert(tableChanges.updated, { onConflict: 'id' });
            if (error) throw new Error(error.message);
          }

          // Delete records by ID
          if (tableChanges.deleted?.length) {
            const { error } = await supabase
              .from(table)
              .delete()
              .in('id', tableChanges.deleted);
            if (error) throw new Error(error.message);
          }
        }
      },
      migrationsEnabledAtVersion: 1,
    });

    setSyncStatus('synced');
  } catch (error) {
    console.error('Sync failed:', error);
    setSyncStatus('pending');
  } finally {
    isSyncing = false;
  }
}

What does this code do?

The sync function wraps WatermelonDB's synchronize() with two key callbacks:

  • pullChanges queries each Supabase table for records with an updated_at newer than lastPulledAt. It splits results into created vs updated based on created_at timestamps.
  • pushChanges upserts created and updated records to Supabase. The onConflict: 'id' option implements last-write-wins. If two workers edit the same record, the most recent updated_at value wins.
  • The isSyncing guard prevents concurrent sync calls from colliding if multiple reconnect events fire in quick succession.
  • Save src/services/syncService.ts.

Seeing TypeScript errors?

Make sure you have @supabase/supabase-js and @nozbe/watermelondb installed. The Database type comes from @nozbe/watermelondb directly, and SupabaseClient from @supabase/supabase-js.

Help me fix TypeScript errors in my sync service

Build the sync status indicator and wire up auto-sync

Workers need a visual cue so they know whether their data is up to date, waiting to sync, or actively syncing. You will build a small header component and wire up NetInfo to trigger sync automatically when the device comes back online.

  • Create src/components/SyncStatusIndicator.tsx with the following code:
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { subscribeSyncStatus, SyncStatus } from '../services/syncService';

export default function SyncStatusIndicator() {
  const [status, setStatus] = useState<SyncStatus>('synced');

  useEffect(() => {
    // Subscribe to sync status changes
    const unsubscribe = subscribeSyncStatus(setStatus);
    return unsubscribe;
  }, []);

  return (
    <View style={styles.container}>
      {status === 'syncing' && (
        <ActivityIndicator size="small" color="#ffffff" />
      )}
      {status === 'synced' && (
        <Text style={styles.syncedIcon}>\u2713</Text>
      )}
      {status === 'pending' && (
        <Text style={styles.pendingIcon}>\u23F0</Text>
      )}
      <Text style={styles.label}>
        {status === 'synced' && 'Synced'}
        {status === 'syncing' && 'Syncing...'}
        {status === 'pending' && 'Pending'}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 8,
    paddingVertical: 4,
  },
  syncedIcon: {
    color: '#4CAF50',
    fontSize: 16,
    marginRight: 4,
  },
  pendingIcon: {
    color: '#FF9800',
    fontSize: 16,
    marginRight: 4,
  },
  label: {
    color: '#ffffff',
    fontSize: 12,
  },
});

What does this component do?

The SyncStatusIndicator subscribes to the sync status observable you built in syncService.ts. It renders a green checkmark when synced, an orange clock when pending, and a spinning activity indicator when actively syncing.

The useEffect cleanup function calls the unsubscribe returned by subscribeSyncStatus(). This prevents memory leaks if the component unmounts.

  • Save src/components/SyncStatusIndicator.tsx.
  • Open App.tsx and add these imports at the top of the file:
import NetInfo from '@react-native-community/netinfo';
import SyncStatusIndicator from './src/components/SyncStatusIndicator';
import { sync } from './src/services/syncService';
import { database } from './src/database';
import { supabase } from './src/lib/supabase';
  • Inside your root component (below the existing providers), add a useEffect that sets up the NetInfo listener:
// Auto-sync when device reconnects
useEffect(() => {
  const unsubscribe = NetInfo.addEventListener((state) => {
    if (state.isConnected) {
      // Trigger sync when connectivity is restored
      sync(database, supabase);
    }
  });

  return () => unsubscribe();
}, []);

What does the NetInfo listener do?

NetInfo.addEventListener fires every time the device's network state changes. When state.isConnected becomes true, it calls sync() to push pending local changes and pull remote updates.

The isSyncing guard inside the sync service prevents duplicate calls if multiple reconnect events fire rapidly.

  • Add <SyncStatusIndicator /> to your app's header area so it is visible on every screen.
  • Save App.tsx.
  • Rebuild and run the app on your physical device.
  • You should see the sync status indicator in the app header showing a green checkmark once the initial sync completes.

Test offline sync manually

Toggle airplane mode on your device to simulate offline usage. Create a product or place an item while offline. The indicator should show "Pending".

When you turn airplane mode off, it should briefly show "Syncing..." and then return to the green checkmark.

Sync indicator stuck on Pending?

Check your Supabase project URL and anon key in .env. Make sure the environment variables are prefixed with EXPO_PUBLIC_ so they are accessible in the client bundle.

Also verify that your Supabase tables have an updated_at column. The pull logic filters on this column to fetch only recent changes.

Help me debug my sync engine

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: src/services/syncService.ts, src/components/SyncStatusIndicator.tsx, and App.tsx.

ⓧ I'd like to double check the full code

import { synchronize } from '@nozbe/watermelondb/sync';
import { Database } from '@nozbe/watermelondb';
import { SupabaseClient } from '@supabase/supabase-js';

// Sync status observable for UI consumption
export type SyncStatus = 'synced' | 'pending' | 'syncing';

let currentSyncStatus: SyncStatus = 'synced';
const listeners: Array<(status: SyncStatus) => void> = [];

// Allow components to subscribe to sync status changes
export function subscribeSyncStatus(listener: (status: SyncStatus) => void) {
  listeners.push(listener);
  // Immediately emit current status
  listener(currentSyncStatus);
  return () => {
    const index = listeners.indexOf(listener);
    if (index > -1) listeners.splice(index, 1);
  };
}

function setSyncStatus(status: SyncStatus) {
  currentSyncStatus = status;
  listeners.forEach((listener) => listener(status));
}

export function getSyncStatus(): SyncStatus {
  return currentSyncStatus;
}

// All tables that participate in sync
const SYNC_TABLES = [
  'warehouses',
  'zones',
  'shelves',
  'slots',
  'products',
  'activity_log',
];

// Guard against concurrent syncs
let isSyncing = false;

export async function sync(database: Database, supabase: SupabaseClient) {
  if (isSyncing) {
    console.log('Sync already in progress, skipping');
    return;
  }

  isSyncing = true;
  setSyncStatus('syncing');

  try {
    await synchronize({
      database,
      pullChanges: async ({ lastPulledAt }) => {
        // Fetch all records updated since last pull from each table
        const changes: Record<string, any> = {};
        const timestamp = Date.now();

        for (const table of SYNC_TABLES) {
          let query = supabase.from(table).select('*');

          // Only fetch records newer than last pull
          if (lastPulledAt) {
            query = query.gt('updated_at', new Date(lastPulledAt).toISOString());
          }

          const { data: records, error } = await query;
          if (error) throw new Error(error.message);

          // Separate into created vs updated based on created_at
          const createdRecords: any[] = [];
          const updatedRecords: any[] = [];

          (records || []).forEach((record: any) => {
            if (!lastPulledAt || new Date(record.created_at).getTime() > lastPulledAt) {
              createdRecords.push(record);
            } else {
              updatedRecords.push(record);
            }
          });

          // Fetch deleted record IDs (soft-delete pattern)
          let deletedIds: string[] = [];
          if (lastPulledAt) {
            const { data: deleted } = await supabase
              .from(table)
              .select('id')
              .eq('is_deleted', true)
              .gt('updated_at', new Date(lastPulledAt).toISOString());
            deletedIds = (deleted || []).map((r: any) => r.id);
          }

          changes[table] = {
            created: createdRecords,
            updated: updatedRecords,
            deleted: deletedIds,
          };
        }

        return { changes, timestamp };
      },
      pushChanges: async ({ changes }) => {
        // Push local creates, updates, and deletes to Supabase
        for (const table of SYNC_TABLES) {
          const tableChanges = changes[table];
          if (!tableChanges) continue;

          // Upsert created records (last-write-wins on conflict)
          if (tableChanges.created?.length) {
            const { error } = await supabase
              .from(table)
              .upsert(tableChanges.created, { onConflict: 'id' });
            if (error) throw new Error(error.message);
          }

          // Upsert updated records (last-write-wins via updated_at)
          if (tableChanges.updated?.length) {
            const { error } = await supabase
              .from(table)
              .upsert(tableChanges.updated, { onConflict: 'id' });
            if (error) throw new Error(error.message);
          }

          // Delete records by ID
          if (tableChanges.deleted?.length) {
            const { error } = await supabase
              .from(table)
              .delete()
              .in('id', tableChanges.deleted);
            if (error) throw new Error(error.message);
          }
        }
      },
      migrationsEnabledAtVersion: 1,
    });

    setSyncStatus('synced');
  } catch (error) {
    console.error('Sync failed:', error);
    setSyncStatus('pending');
  } finally {
    isSyncing = false;
  }
}
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { subscribeSyncStatus, SyncStatus } from '../services/syncService';

export default function SyncStatusIndicator() {
  const [status, setStatus] = useState<SyncStatus>('synced');

  useEffect(() => {
    // Subscribe to sync status changes
    const unsubscribe = subscribeSyncStatus(setStatus);
    return unsubscribe;
  }, []);

  return (
    <View style={styles.container}>
      {status === 'syncing' && (
        <ActivityIndicator size="small" color="#ffffff" />
      )}
      {status === 'synced' && (
        <Text style={styles.syncedIcon}>\u2713</Text>
      )}
      {status === 'pending' && (
        <Text style={styles.pendingIcon}>\u23F0</Text>
      )}
      <Text style={styles.label}>
        {status === 'synced' && 'Synced'}
        {status === 'syncing' && 'Syncing...'}
        {status === 'pending' && 'Pending'}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 8,
    paddingVertical: 4,
  },
  syncedIcon: {
    color: '#4CAF50',
    fontSize: 16,
    marginRight: 4,
  },
  pendingIcon: {
    color: '#FF9800',
    fontSize: 16,
    marginRight: 4,
  },
  label: {
    color: '#ffffff',
    fontSize: 12,
  },
});

Your warehouse app now syncs automatically. All the core features still work fully offline. When connectivity returns, changes flow in both directions without the worker lifting a finger. Next up, you will build the photo upload queue so product and shelf images make it to the cloud too.

Implement Photo Upload Sync

Your data sync engine pushes and pulls WatermelonDB records to Supabase, but product photos still live only on the device. If a worker's phone breaks or they log in on a different device, every photo is gone.

In this step, you'll build a photo upload queue that detects unsynced photos and pushes them to Supabase Storage when the device is online. You'll also add a fallback display helper so the app shows local photos when offline and remote URLs when available.

In this step, get ready to:
  • Build a photo upload service that queues and uploads pending photos to Supabase Storage.
  • Add retry logic with exponential backoff for failed uploads.
  • Integrate photo upload into the reconnect flow so uploads trigger automatically alongside data sync.
Build the photo upload service

The upload service needs to find every product with a local photo that hasn't been uploaded yet, read the file as base64, and push it to the warehouse-photos bucket you created earlier in Supabase.

  • Install the base64-arraybuffer package, which converts base64 strings to ArrayBuffers that Supabase Storage accepts, by running:
npm install base64-arraybuffer
  • Create a new file at src/services/photoUploadService.ts and add the following code:
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { Database } from '@nozbe/watermelondb';
import { Q } from '@nozbe/watermelondb';
import { SupabaseClient } from '@supabase/supabase-js';
import Product from '../database/models/Product';

// Maximum number of retry attempts per photo
const MAX_RETRIES = 3;

// Base delay in milliseconds for exponential backoff
const BASE_DELAY_MS = 1000;

/**
 * Uploads all pending product photos to Supabase Storage.
 * Queries products that have a local photo but no remote URL yet.
 */
export async function uploadPendingPhotos(
  database: Database,
  supabase: SupabaseClient
): Promise<void> {
  // Find all products with a local photo that hasn't been uploaded
  const productsCollection = database.get<Product>('products');
  const pendingProducts = await productsCollection
    .query(
      Q.where('photo_local_path', Q.notEq(null)),
      Q.where('photo_remote_url', null)
    )
    .fetch();

  // Upload each pending photo sequentially to avoid overwhelming bandwidth
  for (const product of pendingProducts) {
    await uploadPhotoWithRetry(product, database, supabase);
  }
}

/**
 * Attempts to upload a single product's photo with exponential backoff.
 */
async function uploadPhotoWithRetry(
  product: Product,
  database: Database,
  supabase: SupabaseClient
): Promise<void> {
  let attempts = 0;

  while (attempts < MAX_RETRIES) {
    try {
      // Read the local file as a base64 string
      const base64 = await FileSystem.readAsStringAsync(
        product.photoLocalPath,
        { encoding: FileSystem.EncodingType.Base64 }
      );

      // Generate a unique filename using product ID and timestamp
      const filename = `products/${product.id}_${Date.now()}.jpg`;

      // Upload the decoded ArrayBuffer to Supabase Storage
      const { error: uploadError } = await supabase.storage
        .from('warehouse-photos')
        .upload(filename, decode(base64), {
          contentType: 'image/jpeg',
        });

      if (uploadError) {
        throw uploadError;
      }

      // Get the public URL for the uploaded photo
      const { data: urlData } = supabase.storage
        .from('warehouse-photos')
        .getPublicUrl(filename);

      // Update the local WatermelonDB record with the remote URL
      await database.write(async () => {
        await product.update((p: Product) => {
          p.photoRemoteUrl = urlData.publicUrl;
        });
      });

      // Upload succeeded, exit the retry loop
      return;
    } catch (error) {
      attempts += 1;
      if (attempts >= MAX_RETRIES) {
        console.warn(
          `Failed to upload photo for product ${product.id} after ${MAX_RETRIES} attempts`,
          error
        );
        return;
      }
      // Wait with exponential backoff before retrying
      const delay = BASE_DELAY_MS * Math.pow(2, attempts - 1);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

/**
 * Returns the best available image URI for display.
 * Prefers the remote URL (available after upload), falls back to local path.
 */
export function getDisplayUri(product: Product): string | null {
  if (product.photoRemoteUrl) {
    return product.photoRemoteUrl;
  }
  return product.photoLocalPath || null;
}

What does this code do?

The uploadPendingPhotos function queries WatermelonDB for any Product where photo_local_path is set but photo_remote_url is still null. For each match, it reads the local file as base64, converts it to an ArrayBuffer using decode() from base64-arraybuffer, and uploads it to Supabase Storage.

The retry logic uses exponential backoff. The first retry waits 1 second, the second waits 2 seconds, and the third waits 4 seconds. After 3 failed attempts, it logs a warning and moves on to the next photo instead of blocking the queue.

The getDisplayUri helper gives screens a single function to call for the image source. It returns the remote URL if the photo has been uploaded, or the local file path if the upload is still pending.

  • Save src/services/photoUploadService.ts.

Seeing a TypeScript error on Product fields?

Make sure your Product model in src/database/models/Product.ts has photoLocalPath and photoRemoteUrl fields decorated with @field. These correspond to the photo_local_path and photo_remote_url columns in your WatermelonDB schema.

help me fix TypeScript errors in my photo upload service

Trigger photo upload on reconnect

The upload service is ready, but nothing calls it yet. You need to hook it into the same reconnect listener that already triggers data sync in App.tsx.

  • In App.tsx, find the import section at the top of the file and add this import:
import { uploadPendingPhotos } from './src/services/photoUploadService';
  • In App.tsx, find the NetInfo listener where syncService.sync() is called on reconnect.
  • Find this line inside the reconnect handler:
syncService.sync(database, supabase);
  • Add the photo upload call directly below it, so both run when connectivity returns:
syncService.sync(database, supabase);
uploadPendingPhotos(database, supabase);

Why trigger both at the same time?

Data sync pushes the product records (names, dimensions, slot assignments) to Supabase first. The photo upload then iterates the same products and pushes their images. Running them together on reconnect means the cloud database stays consistent with the photos bucket.

  • Save App.tsx.

Photos not uploading on reconnect?

Check that uploadPendingPhotos is being called inside the same condition that checks for connectivity (e.g., isConnected === true). Also confirm that at least one Product in your local database has photo_local_path set and photo_remote_url as null.

help me debug why my photo uploads aren't triggering on reconnect

Use the display helper in your screens

Any screen that shows a product photo should use getDisplayUri instead of reading photo_local_path directly. This gives the app transparent fallback behavior: remote URL when uploaded, local path when offline.

  • In any screen that renders a product image (such as ProductSearchScreen.tsx or ShelfMapScreen.tsx), import the helper:
import { getDisplayUri } from '../services/photoUploadService';
  • Replace any direct reference to product.photoLocalPath in an Image component's source prop with getDisplayUri(product):
// Before: only uses local path
<Image source={{ uri: product.photoLocalPath }} />

// After: uses remote URL when available, falls back to local
<Image source={{ uri: getDisplayUri(product) || undefined }} />

What does getDisplayUri do?

This function checks whether a product has a photoRemoteUrl (meaning the upload succeeded). If it does, the app loads the image from Supabase Storage. If not, it serves the local file path so the image still renders while offline or before the upload completes.

  • Save any files you updated.
  • Run the app on your device, take a product photo while in airplane mode, then turn connectivity back on.
  • Open the Supabase dashboard and navigate to **Storage**, then the warehouse-photos bucket. You should see the uploaded photo file appear within a few seconds of reconnecting.

Photo doesn't appear in the bucket?

Confirm that your Supabase Storage bucket warehouse-photos has a policy allowing authenticated users to upload. Also check the device console for retry-related warnings, which indicate the upload is failing repeatedly.

help me troubleshoot why my photos aren't appearing in the Supabase Storage bucket

✔️ Awesome, I've got everything!

Great. Double check you've saved all files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/services/photoUploadService.ts file:

import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { Database } from '@nozbe/watermelondb';
import { Q } from '@nozbe/watermelondb';
import { SupabaseClient } from '@supabase/supabase-js';
import Product from '../database/models/Product';

// Maximum number of retry attempts per photo
const MAX_RETRIES = 3;

// Base delay in milliseconds for exponential backoff
const BASE_DELAY_MS = 1000;

/**
 * Uploads all pending product photos to Supabase Storage.
 * Queries products that have a local photo but no remote URL yet.
 */
export async function uploadPendingPhotos(
  database: Database,
  supabase: SupabaseClient
): Promise<void> {
  // Find all products with a local photo that hasn't been uploaded
  const productsCollection = database.get<Product>('products');
  const pendingProducts = await productsCollection
    .query(
      Q.where('photo_local_path', Q.notEq(null)),
      Q.where('photo_remote_url', null)
    )
    .fetch();

  // Upload each pending photo sequentially to avoid overwhelming bandwidth
  for (const product of pendingProducts) {
    await uploadPhotoWithRetry(product, database, supabase);
  }
}

/**
 * Attempts to upload a single product's photo with exponential backoff.
 */
async function uploadPhotoWithRetry(
  product: Product,
  database: Database,
  supabase: SupabaseClient
): Promise<void> {
  let attempts = 0;

  while (attempts < MAX_RETRIES) {
    try {
      // Read the local file as a base64 string
      const base64 = await FileSystem.readAsStringAsync(
        product.photoLocalPath,
        { encoding: FileSystem.EncodingType.Base64 }
      );

      // Generate a unique filename using product ID and timestamp
      const filename = `products/${product.id}_${Date.now()}.jpg`;

      // Upload the decoded ArrayBuffer to Supabase Storage
      const { error: uploadError } = await supabase.storage
        .from('warehouse-photos')
        .upload(filename, decode(base64), {
          contentType: 'image/jpeg',
        });

      if (uploadError) {
        throw uploadError;
      }

      // Get the public URL for the uploaded photo
      const { data: urlData } = supabase.storage
        .from('warehouse-photos')
        .getPublicUrl(filename);

      // Update the local WatermelonDB record with the remote URL
      await database.write(async () => {
        await product.update((p: Product) => {
          p.photoRemoteUrl = urlData.publicUrl;
        });
      });

      // Upload succeeded, exit the retry loop
      return;
    } catch (error) {
      attempts += 1;
      if (attempts >= MAX_RETRIES) {
        console.warn(
          `Failed to upload photo for product ${product.id} after ${MAX_RETRIES} attempts`,
          error
        );
        return;
      }
      // Wait with exponential backoff before retrying
      const delay = BASE_DELAY_MS * Math.pow(2, attempts - 1);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

/**
 * Returns the best available image URI for display.
 * Prefers the remote URL (available after upload), falls back to local path.
 */
export function getDisplayUri(product: Product): string | null {
  if (product.photoRemoteUrl) {
    return product.photoRemoteUrl;
  }
  return product.photoLocalPath || null;
}

Your photos now sync to the cloud alongside your data. Workers can capture product photos completely offline, and the app takes care of uploading them the moment connectivity returns.

Secret mission

Add a Barcode Scanner and Conflict Resolution

Your warehouse app already handles product measurement and placement. But what happens when a worker scans a barcode instead of typing a product name? And what about when two workers try to claim the same shelf slot at the same time? This mission adds barcode scanning for faster product identification and conflict resolution for real multi-worker scenarios.

Clean Up Your Resources

Clean Up Your Resources

This project runs on the Supabase free tier and uses on-device AI, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Supabase project (database, auth, and storage bucket on the free tier).
  • Local AI model file (~2.4 GB stored on your Android device).
  • Local project files on your computer (Expo project folder).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue soon.

  • Your Supabase project on the free tier remains active indefinitely with no charges.
  • The AI model file stays on your device and is ready for immediate use.
  • Your local project files remain on your computer.

Pause - I'll come back to this later

Free up device storage while keeping your cloud data intact. Your Supabase free-tier project automatically pauses after 1 week of inactivity and resumes instantly when you return.

  • No action needed for Supabase. Free-tier projects pause automatically after inactivity.
  • Optionally uninstall the app from your Android device to reclaim ~2.4 GB of model storage. You can re-download the model when you resume development.
  • Keep your local project files so you can pick up where you left off.

Delete - I don't want to use this again

Remove all project resources completely and start fresh.

  • Delete your Supabase project by opening your project in the Supabase Dashboard, navigating to the General Settings page, and scrolling to the bottom to find the project deletion option. This permanently removes all data including your database, auth users, and storage bucket.
  • Uninstall the warehouse organizer app from your Android device to remove the ~2.4 GB AI model and all locally stored photos.
  • Delete your local project folder from your computer by running the following commands.

macOS

cd ~/Desktop
rm -rf warehouse-organizer

Windows

cd ~\Desktop
Remove-Item -Recurse -Force warehouse-organizer

That's a Wrap!

That's a Wrap!

You just built a full-stack warehouse organizer app powered by on-device AI. Workers can photograph products, get instant dimension estimates from Gemma 4 E2B without any internet connection, and track every placement in an offline-first database that syncs to the cloud.

You've learned how to:

  • Integrated on-device multimodal AI (Gemma 4 E2B via react-native-ai-core) to estimate product dimensions from photos with zero cloud API calls.
  • Built a complete offline-first relational database in WatermelonDB with a Warehouse > Zones > Shelves > Slots > Products hierarchy and reactive queries.
  • Implemented cloud sync with Supabase using a pull/push protocol, last-write-wins conflict resolution, and automatic reconnect detection.
  • Added barcode scanning with product auto-fill and multi-worker conflict resolution with manual override when two workers claim the same slot.

Ready to quiz yourself?