Files
petfeed/mobile/app/_layout.tsx
T
admin c35d4cda46 feat(mobile): Phase 1 — real Feed/Explore/Notifications/Pets tabs
Moves the mobile app from a connectivity scaffold to a real Phase 1 app:

- Migrate off Expo Go onto a local custom dev client (expo-dev-client,
  built via expo prebuild + expo run:android against the local Android
  SDK — no EAS account needed). Needed because react-native-mmkv requires
  native code Expo Go doesn't ship.
- Persisted, MMKV-backed TanStack Query cache (PersistQueryClientProvider)
  plus expo-image, so a relaunch renders the last-known feed/pets/
  notifications instantly instead of a blank screen while refetching.
- ActivePetContext: every pet-scoped procedure requires an explicit petId
  server-side (no server-side "current pet"), ported from web's
  ActivePetContext, MMKV-backed instead of localStorage.
- Bottom Tabs (Feed/Explore/Notifications/Pets) mirroring the web app's
  actual mobile nav, with Messages as a header button rather than a tab
  (matches web). Feed, PostCard, PawButton reactions, pet discovery grid,
  notifications list, and a conversations list are all wired to the real
  backend and verified against the live database.
- Design tokens/components extended with PostCard/PawButton/PetAvatar/
  PetIdentityBadges, all styled from the existing src/theme/tokens.ts.
- Fixed a react/react-dom version mismatch surfaced along the way, and
  handle Clerk's needs_client_trust (Device Trust) sign-in status with an
  email-code step.

Verified end-to-end in the Android emulator against pawfeed.org: feed
scrolls and paginates with real posts/images, paw reactions toggle
optimistically and persist, pet switching updates every screen, and
notifications/explore show live data.
2026-09-05 13:19:01 +02:00

101 lines
3.0 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import { Slot, useRouter, useSegments } from "expo-router";
import { ClerkProvider, useAuth } from "@clerk/expo";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
useFonts,
Inter_400Regular,
Inter_600SemiBold,
Inter_700Bold,
Inter_800ExtraBold,
} from "@expo-google-fonts/inter";
import { secureTokenCache } from "../src/lib/tokenCache";
import { env } from "../src/lib/env";
import { TRPCProvider, createMobileTRPCClient, makeQueryClient } from "../src/api/trpc";
import { queryPersister } from "../src/lib/storage";
import { ActivePetProvider } from "../src/context/ActivePetContext";
import { colors } from "../src/theme/tokens";
/**
* Redirects between the (auth) and (app) route groups based on Clerk's
* session state. Mirrors Clerk's official expo-router integration pattern —
* plain useAuth()/useSegments() rather than expo-router's typed route
* guards, since (auth)/(app) group membership isn't otherwise derivable.
*/
function useProtectedRoute() {
const { isLoaded, isSignedIn } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (!isLoaded) return;
const inAuthGroup = segments[0] === "(auth)";
if (!isSignedIn && !inAuthGroup) {
router.replace("/(auth)/sign-in");
} else if (isSignedIn && inAuthGroup) {
router.replace("/(app)");
}
}, [isLoaded, isSignedIn, segments, router]);
}
function ApiProviders({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth();
const [queryClient] = useState(() => makeQueryClient());
const trpcClient = useMemo(() => createMobileTRPCClient(() => getToken()), [getToken]);
return (
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: queryPersister }}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<ActivePetProvider>{children}</ActivePetProvider>
</TRPCProvider>
</PersistQueryClientProvider>
);
}
function LoadingScreen() {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
function InitialLayout() {
const { isLoaded } = useAuth();
useProtectedRoute();
if (!isLoaded) {
return <LoadingScreen />;
}
return (
<ApiProviders>
<Slot />
</ApiProviders>
);
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_600SemiBold,
Inter_700Bold,
Inter_800ExtraBold,
});
if (!fontsLoaded) {
return <LoadingScreen />;
}
return (
<ClerkProvider publishableKey={env.clerkPublishableKey} tokenCache={secureTokenCache}>
<SafeAreaProvider>
<InitialLayout />
</SafeAreaProvider>
</ClerkProvider>
);
}