Mobile App

Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components

Stack

CategoryChoiceNotes
FrameworkExpo + Expo RouterFaster dev, better tooling
StylingNativeWind (Tailwind for RN)Same class syntax as web
AuthConvexAuthProvider + expo-secure-storeTokens in Keychain / EncryptedSharedPreferences
TabsNativeTabs from expo-router/unstable-native-tabsNative iOS/Android tab bar
ImagesImage from expo-imageBetter memory management than RN Image

SafeAreaView

Every screen MUST be wrapped in <SafeAreaView> from react-native-safe-area-context.
import { SafeAreaView } from 'react-native-safe-area-context';

export default function ProfileScreen() {
  return (
    <SafeAreaView className="flex-1 bg-[#0A1A33]" edges={['top']}>
      <View className="flex-1 px-4">
        {/* content */}
      </View>
    </SafeAreaView>
  );
}
  • edges={['top']} — for tab screens (bottom handled by tabs)
  • edges={['top', 'bottom']} — for screens needing both

Colors — Hex Only

All color values MUST use hex format (#RRGGBB or #RGB). No named colors, no rgb(), no CSS variables.
// ✅ GOOD — hex
<Text className="text-[#4A6AB3]">Hello</Text>
<View className="bg-[#0A1A33]" />

// ❌ BAD — named color
<Text style={{ color: 'white' }}>Hello</Text>
For semi-transparent colors, use Tailwind opacity suffix:
<View className="bg-[#0A1A33]/50" />
Centralize colors in src/constants/app-theme.ts:
export const Colors = {
  primary: '#4A6AB3',
  background: '#0A1A33',
  textSecondary: '#737A8C',
};

NativeTabs

Use NativeTabs from expo-router/unstable-native-tabs — not @react-navigation/bottom-tabs.
import { NativeTabs } from 'expo-router/unstable-native-tabs';

export default function TabLayout() {
  return (
    <NativeTabs
      backgroundColor={Colors.background}
      blurEffect="systemMaterialDark"
      iconColor={{
        default: Colors.tabIconDefault,
        selected: Colors.tabIconSelected,
      }}
    >
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Icon sf="house.fill" />
        <NativeTabs.Trigger.Label hidden />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
SF Symbols on iOS via the sf prop. Always hide labels for icon-only tabs: <NativeTabs.Trigger.Label hidden />.

Auth

// apps/mobile/src/lib/ConvexClientProvider.tsx
import { ConvexAuthProvider } from '@convex-dev/auth/react';
import { ConvexReactClient } from 'convex/react';
import * as SecureStore from 'expo-secure-store';

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);

const secureStorage = {
  getItem: SecureStore.getItemAsync,
  setItem: SecureStore.setItemAsync,
  removeItem: SecureStore.deleteItemAsync,
};

export function ConvexClientProvider({ children }: { children: React.ReactNode }) {
  return (
    <ConvexAuthProvider client={convex} storage={secureStorage}>
      {children}
    </ConvexAuthProvider>
  );
}
Tokens are stored in expo-secure-store (Keychain on iOS, EncryptedSharedPreferences on Android) — never in plain AsyncStorage.

Image Handling

Use Image from expo-image (not React Native’s built-in Image):
import { Image } from 'expo-image';

// ✅ GOOD
<Image
  source={{ uri: item.imageUrl }}
  style={styles.image}
  contentFit="cover"
  transition={200}
/>

// ❌ BAD — React Native Image
<Image source={{ uri: item.imageUrl }} style={styles.image} />

expo-router Conventions

Layout Files

// app/_layout.tsx — Root layout
export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <SafeAreaProvider>
        <ConvexClientProvider>
          <Stack screenOptions={{ headerShown: false }}>
            <Stack.Screen name="(tabs)" />
            <Stack.Screen name="(auth)" />
          </Stack>
        </ConvexClientProvider>
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

// app/(tabs)/_layout.tsx — Tab layout (NativeTabs)
export default function TabLayout() {
  return <NativeTabs>{/* triggers */}</NativeTabs>;
}

Screen Files

Each screen is a default export. Use useRouter from expo-router:
import { useRouter } from 'expo-router';

export default function HomeScreen() {
  const router = useRouter();
  return <TouchableOpacity onPress={() => router.push('/profile')} />;
}