Catches up several native builds' worth of work that was already tested live and shipped to the Play Store (per mobile/found.md TODO Neu4-Neu6) but never made it into a git commit: - Google SSO via expo-auth-session/expo-web-browser (GoogleSignInButton, useWarmUpBrowser) on sign-in/sign-up. - Upload-time image downscaling (resize-image.ts) to fix slow first content loads — capped at 1600px, re-encoded JPEG @0.8. - @sentry/react-native (GlitchTip-compatible) crash reporting, plus a 10s auth-loading timeout screen. - Dark-mode splash screen background, breed/birthday editing in PetCreateForm, and the OptionPicker Modal rewrite that fixed the Android nested-scroll dropdown bug across Explore/onboarding/edit. - expo-dev-client removed from the shared plugin list (was silently blocking OTA updates in production), app.json bumped to 1.0.6/ versionCode 7, several expo-* packages patched via `expo install --fix`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { ActivityIndicator, View, Text, Pressable, useColorScheme } 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 * as WebBrowser from "expo-web-browser";
|
|
import * as Sentry from "@sentry/react-native";
|
|
import * as Updates from "expo-updates";
|
|
import { useTranslation } from "react-i18next";
|
|
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, resolveInitialColors } from "../src/context/ThemeContext";
|
|
import { usePushNotifications } from "../src/hooks/usePushNotifications";
|
|
import { fontFamily } from "../src/theme/tokens";
|
|
|
|
// Required once, module-scope, for expo-web-browser to resolve the pending
|
|
// AuthSession promise when the OAuth browser tab redirects back into the
|
|
// app (Google SSO via useSSO()) — otherwise the promise from
|
|
// WebBrowser.openAuthSessionAsync() never settles.
|
|
WebBrowser.maybeCompleteAuthSession();
|
|
|
|
// Self-hosted GlitchTip (Sentry-protocol-compatible) — see .env.example.
|
|
// A no-op when the DSN isn't set (local dev). Deliberately minimal: no
|
|
// sourcemap upload wired into the build (that already fails against this
|
|
// GlitchTip instance for the web app — CSRF 403, see CLAUDE.md), just error
|
|
// + breadcrumb capture so a repeat of an unreproducible field bug (e.g. the
|
|
// stuck-loading-screen report below) has something to look at next time.
|
|
if (env.sentryDsn) {
|
|
Sentry.init({ dsn: env.sentryDsn, tracesSampleRate: 0.1 });
|
|
}
|
|
|
|
const AUTH_LOAD_TIMEOUT_MS = 10_000;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Rendered before ThemeProvider mounts (pre-fonts, pre-auth-resolved), so it
|
|
* can't use useTheme() — resolves the same local-preference-or-system guess
|
|
* ThemeProvider uses for its own first frame, instead of always hardcoding
|
|
* the light palette regardless of device theme.
|
|
*/
|
|
function LoadingScreen() {
|
|
const systemScheme = useColorScheme();
|
|
const themeColors = resolveInitialColors(systemScheme);
|
|
return (
|
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: themeColors.porchWhite }}>
|
|
<ActivityIndicator color={themeColors.marigoldOrange} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* True once `waiting` has stayed true for AUTH_LOAD_TIMEOUT_MS straight —
|
|
* resets if `waiting` goes false first. Exists because Clerk's `isLoaded`
|
|
* has no built-in timeout: a hung silent token-refresh (the stored session
|
|
* JWT is expired on a reopen days later, so Clerk has to hit the network
|
|
* before it can resolve) previously left the loading spinner in
|
|
* app/_layout.tsx spinning forever, with no error and no way to recover
|
|
* short of force-closing the app (field report: stuck loading screen on a
|
|
* second-day reopen, found.md TODO Neu5).
|
|
*/
|
|
function useTimedOut(waiting: boolean, timeoutMs: number): boolean {
|
|
const [timedOut, setTimedOut] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!waiting) return;
|
|
const id = setTimeout(() => setTimedOut(true), timeoutMs);
|
|
return () => clearTimeout(id);
|
|
}, [waiting, timeoutMs]);
|
|
|
|
// `timedOut` only ever flips true, so mask a stale true from a previous
|
|
// wait cycle with the current `waiting` value instead of resetting state
|
|
// synchronously inside the effect above.
|
|
return waiting && timedOut;
|
|
}
|
|
|
|
/**
|
|
* Shown instead of an indefinite spinner once useTimedOut fires. Rendered
|
|
* pre-ThemeProvider like LoadingScreen, so it can't use useTheme() or the
|
|
* themed Button component — same reason LoadingScreen builds its own View
|
|
* instead.
|
|
*/
|
|
function AuthTimeoutScreen() {
|
|
const { t } = useTranslation();
|
|
const systemScheme = useColorScheme();
|
|
const colors = resolveInitialColors(systemScheme);
|
|
const [isRetrying, setIsRetrying] = useState(false);
|
|
|
|
// Captured on mount, not only on a retry tap — a user who just force-closes
|
|
// instead of tapping retry would otherwise leave zero trace of the timeout.
|
|
useEffect(() => {
|
|
Sentry.captureMessage("Auth load timeout — showed retry screen", "warning");
|
|
}, []);
|
|
|
|
const onRetry = async () => {
|
|
setIsRetrying(true);
|
|
Sentry.captureMessage("Auth load timeout — user tapped retry", "warning");
|
|
try {
|
|
await Updates.reloadAsync();
|
|
} catch {
|
|
// No OTA runtime to reload against (e.g. a dev build) — nothing more
|
|
// we can do from here; leave the retry button re-enabled.
|
|
setIsRetrying(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 32, gap: 16, backgroundColor: colors.porchWhite }}>
|
|
<Text style={{ fontFamily: fontFamily.regular, fontSize: 14, color: colors.ink, textAlign: "center" }}>
|
|
{t("common.authLoadTimeout")}
|
|
</Text>
|
|
<Pressable
|
|
onPress={onRetry}
|
|
disabled={isRetrying}
|
|
style={{
|
|
height: 48,
|
|
paddingHorizontal: 24,
|
|
borderRadius: 10,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: colors.marigoldOrange,
|
|
opacity: isRetrying ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{isRetrying ? (
|
|
<ActivityIndicator color={colors.pureWhite} />
|
|
) : (
|
|
<Text style={{ fontFamily: fontFamily.semiBold, fontSize: 14, color: colors.pureWhite }}>{t("common.retry")}</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function InitialLayout() {
|
|
const { isLoaded } = useAuth();
|
|
useProtectedRoute();
|
|
const authTimedOut = useTimedOut(!isLoaded, AUTH_LOAD_TIMEOUT_MS);
|
|
|
|
if (!isLoaded) {
|
|
return authTimedOut ? <AuthTimeoutScreen /> : <LoadingScreen />;
|
|
}
|
|
|
|
return (
|
|
<ApiProviders>
|
|
<Slot />
|
|
</ApiProviders>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Sentry.wrap adds an error boundary around the root component so an
|
|
// otherwise-uncaught render error reports to GlitchTip instead of silently
|
|
// leaving a blank/frozen screen — a no-op when Sentry.init() above never ran
|
|
// (no DSN configured).
|
|
export default Sentry.wrap(RootLayout);
|