Skip to main content

Overview

GAIA’s mobile app is built with React Native and Expo, providing a native mobile experience on iOS and Android. The app shares state management patterns and API integrations with the web app.

Tech Stack

React Native

Cross-platform native mobile framework

Expo 54

Managed workflow with native modules

Expo Router

File-based routing for React Native

Zustand

Shared state management with web

Project Structure

apps/mobile/
├── src/
   ├── app/              # Expo Router pages
   ├── (app)/        # Main app routes
   ├── login/        # Auth screens
   ├── signup/       # Signup flow
   └── _layout.tsx   # Root layout
   ├── features/         # Feature modules
   ├── auth/         # Authentication
   ├── chat/         # Chat interface
   ├── integrations/ # App integrations
   └── notifications/# Push notifications
   ├── components/       # Reusable components
   ├── hooks/            # Custom hooks
   ├── stores/           # Zustand stores
   ├── lib/              # Utilities
   └── shared/           # Shared types/constants
├── assets/               # Images, fonts, etc.
├── app.json              # Expo configuration
├── tailwind.config.js    # TailwindCSS config
└── package.json

App Router (Expo Router)

Expo Router provides file-based routing similar to Next.js:

Root Layout

// src/app/_layout.tsx
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { HeroUINativeProvider } from 'heroui-native';

import { AuthProvider } from '@/features/auth';
import { ChatProvider } from '@/features/chat';
import { QueryProvider } from '@/lib/query-provider';

export default function RootLayout() {
  return (
    <QueryProvider>
      <AuthProvider>
        <ChatProvider>
          <GestureHandlerRootView style={{ flex: 1 }}>
            <HeroUINativeProvider>
              <Stack screenOptions={{ headerShown: false }}>
                <Stack.Screen name="(app)" />
                <Stack.Screen name="login/index" />
                <Stack.Screen name="signup/index" />
              </Stack>
            </HeroUINativeProvider>
          </GestureHandlerRootView>
        </ChatProvider>
      </AuthProvider>
    </QueryProvider>
  );
}
import { router } from 'expo-router';

// Navigate to screen
router.push('/chat');

// Navigate with params
router.push({
  pathname: '/chat/[id]',
  params: { id: '123' },
});

// Go back
router.back();

Native Features

Authentication

// features/auth/hooks/useAuth.ts
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';

WebBrowser.maybeCompleteAuthSession();

export function useAuth() {
  const [request, response, promptAsync] = AuthSession.useAuthRequest(
    {
      clientId: 'your-client-id',
      redirectUri: AuthSession.makeRedirectUri(),
      scopes: ['openid', 'profile', 'email'],
    },
    { authorizationEndpoint: 'https://auth.heygaia.io/authorize' }
  );
  
  useEffect(() => {
    if (response?.type === 'success') {
      const { code } = response.params;
      // Exchange code for token
    }
  }, [response]);
  
  return { signIn: promptAsync };
}

Push Notifications

// features/notifications/hooks/useNotifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';

// Configure notification handler
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export function useNotifications() {
  const [expoPushToken, setExpoPushToken] = useState('');
  
  useEffect(() => {
    registerForPushNotifications();
  }, []);
  
  async function registerForPushNotifications() {
    if (!Device.isDevice) {
      console.log('Must use physical device for Push Notifications');
      return;
    }
    
    const { status: existingStatus } = 
      await Notifications.getPermissionsAsync();
    
    let finalStatus = existingStatus;
    
    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }
    
    if (finalStatus !== 'granted') {
      console.log('Failed to get push token');
      return;
    }
    
    const token = await Notifications.getExpoPushTokenAsync({
      projectId: 'your-project-id',
    });
    
    setExpoPushToken(token.data);
    
    // Send token to backend
    await api.registerPushToken(token.data);
  }
  
  return { expoPushToken };
}

Haptic Feedback

import * as Haptics from 'expo-haptics';

function Button({ onPress }: { onPress: () => void }) {
  const handlePress = () => {
    Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
    onPress();
  };
  
  return <Pressable onPress={handlePress}>...</Pressable>;
}

UI Components

HeroUI Native

import { Button, Card } from 'heroui-native';

export function ChatCard() {
  return (
    <Card>
      <Card.Body>
        <Text>Chat Message</Text>
      </Card.Body>
      <Card.Footer>
        <Button>Reply</Button>
      </Card.Footer>
    </Card>
  );
}

Styled Components with Uniwind

import { View, Text } from 'react-native';
import { useUniwind } from 'uniwind';

export function ChatBubble({ message }: { message: string }) {
  const u = useUniwind();
  
  return (
    <View style={u('bg-blue-500 rounded-lg p-4 mb-2')}>
      <Text style={u('text-white')}>{message}</Text>
    </View>
  );
}

State Management

Mobile app uses Zustand, sharing patterns with the web app:
// stores/chat-store.ts
import { create } from 'zustand';
import type { Message, Conversation } from '@/features/chat/types';

interface ChatState {
  messagesByConversation: Record<string, Message[]>;
  conversations: Conversation[];
  activeChatId: string | null;
  
  setActiveChatId: (id: string | null) => void;
  setMessages: (conversationId: string, messages: Message[]) => void;
  addConversation: (conversation: Conversation) => void;
}

export const useChatStore = create<ChatState>((set) => ({
  messagesByConversation: {},
  conversations: [],
  activeChatId: null,
  
  setActiveChatId: (id) => set({ activeChatId: id }),
  
  setMessages: (conversationId, messages) =>
    set((state) => ({
      messagesByConversation: {
        ...state.messagesByConversation,
        [conversationId]: messages,
      },
    })),
  
  addConversation: (conversation) =>
    set((state) => ({
      conversations: [conversation, ...state.conversations],
    })),
}));

Data Fetching

Use TanStack Query for API calls:
// features/chat/api/chat-api.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.EXPO_PUBLIC_API_URL,
});

export function useMessages(conversationId: string) {
  return useQuery({
    queryKey: ['messages', conversationId],
    queryFn: async () => {
      const { data } = await api.get(`/chat/${conversationId}/messages`);
      return data;
    },
  });
}

export function useSendMessage() {
  return useMutation({
    mutationFn: async ({ 
      conversationId, 
      content 
    }: { 
      conversationId: string; 
      content: string; 
    }) => {
      const { data } = await api.post(
        `/chat/${conversationId}/messages`,
        { content }
      );
      return data;
    },
  });
}

Performance Optimization

FlashList for Large Lists

import { FlashList } from '@shopify/flash-list';

export function MessageList({ messages }: { messages: Message[] }) {
  return (
    <FlashList
      data={messages}
      renderItem={({ item }) => <MessageBubble message={item} />}
      estimatedItemSize={80}
      keyExtractor={(item) => item.id}
    />
  );
}

Image Optimization

import { Image } from 'expo-image';

export function Avatar({ uri }: { uri: string }) {
  return (
    <Image
      source={{ uri }}
      style={{ width: 40, height: 40, borderRadius: 20 }}
      contentFit="cover"
      transition={200}
    />
  );
}

Development Workflow

Running the App

# Start Expo dev server
nx dev mobile
# or
pnpm start

# Then scan QR code with Expo Go app

Building for Production

# Install EAS CLI
npm install -g eas-cli

# Login to Expo
eas login

# Configure project
eas build:configure

# Build for iOS
eas build --platform ios

# Build for Android
eas build --platform android

App Configuration

// app.json
{
  "expo": {
    "name": "GAIA",
    "slug": "gaia-mobile",
    "version": "0.3.0",
    "scheme": "gaia",
    "platforms": ["ios", "android"],
    "ios": {
      "bundleIdentifier": "io.heygaia.mobile",
      "supportsTablet": true,
      "infoPlist": {
        "NSMicrophoneUsageDescription": "GAIA needs microphone access for voice features"
      }
    },
    "android": {
      "package": "io.heygaia.mobile",
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#000000"
      },
      "permissions": [
        "CAMERA",
        "RECORD_AUDIO",
        "NOTIFICATIONS"
      ]
    },
    "plugins": [
      "expo-router",
      "expo-font",
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#ffffff"
        }
      ]
    ]
  }
}

Environment Variables

# .env
EXPO_PUBLIC_API_URL=https://api.heygaia.io
EXPO_PUBLIC_WS_URL=wss://api.heygaia.io/ws
Access in code:
const apiUrl = process.env.EXPO_PUBLIC_API_URL;

Debugging

React DevTools

# Install React DevTools
npm install -g react-devtools

# Start DevTools
react-devtools

Flipper

# Install Flipper
brew install --cask flipper

# Run app in dev mode
nx dev mobile

Common Issues

Clear Metro cache:
npx expo start --clear
Rebuild the app:
# iOS
npx expo run:ios --clean

# Android
npx expo run:android --clean
Reset simulators:
# iOS
xcrun simctl erase all

# Android
adb kill-server && adb start-server

Next Steps

Web App

Learn about the Next.js web application

State Management

Explore Zustand state patterns

Component Structure

Understand component organization

API Integration

Connect to the backend API

Build docs developers (and LLMs) love