Files
petfeed/mobile/app/_layout.tsx
T
admin 0408be2063 feat(mobile): add onboarding flow, dark mode, and bugfixes
- 5-step onboarding (guidelines, upload consent, spider opt-in, pet
  creation, avatar) that gates first launch until a pet exists
- Add/delete pet flows on the Pets tab, with a shared OptionPicker
  (ScrollView-based, not FlatList, to avoid nested-VirtualizedList
  warnings inside onboarding's outer ScrollView)
- App-wide light/dark/auto theme via ThemeContext, synced with the
  existing owner.getThemePreference/setThemePreference backend
  procedures; core navigation chrome, Feed, Pets tab, and shared
  primitives (Button, TextField, Card, PetAvatar, PostCard, StoryTray)
  converted to the makeStyles(colors) pattern
- found.md fixes: safe-area insets on image/story viewers so controls
  clear the status bar, auto-mark-all-read on the notifications tab,
  and an Explore species/breed filter
2026-09-06 16:14:44 +02:00

150 lines
4.8 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 { StatusBar } from "expo-status-bar";
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, useActivePet } from "../src/context/ActivePetContext";
import { ThemeProvider, useTheme } from "../src/context/ThemeContext";
import { usePushNotifications } from "../src/hooks/usePushNotifications";
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}>
<ThemeProvider>
<ActivePetProvider>
<ThemedStatusBar />
<PushNotificationRegistrar />
<OnboardingRedirect />
{children}
</ActivePetProvider>
</ThemeProvider>
</TRPCProvider>
</PersistQueryClientProvider>
);
}
/** Status bar icon color follows the resolved theme (dark icons on light, light icons on dark). */
function ThemedStatusBar() {
const { scheme } = useTheme();
return <StatusBar style={scheme === "dark" ? "light" : "dark"} />;
}
/** No UI — just runs the push-token registration side effect once signed in. */
function PushNotificationRegistrar() {
usePushNotifications();
return null;
}
/**
* Sends a signed-in owner with zero pets to /onboarding/pet — mirrors web's
* (app)/layout.tsx redirect-to-onboarding-if-zero-pets check, which mobile
* has no equivalent of yet. Lives inside ActivePetProvider (needs `pets`
* from there), unlike useProtectedRoute above which only needs Clerk's
* auth state and runs before ApiProviders even mounts.
*/
function OnboardingRedirect() {
const { pets, isPending } = useActivePet();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
// isPending (not isLoading) — stays true through the disabled-query
// window before Clerk confirms isSignedIn, when `pets` is still
// undefined and would otherwise default to [] and look like "no pets".
if (isPending) return;
const inOnboarding = segments[0] === "onboarding";
if (pets.length === 0 && !inOnboarding) {
router.replace("/onboarding/pet");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending, pets.length, segments.join("/")]);
return null;
}
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>
);
}