Files
adminandClaude Sonnet 5 99575ddc82 fix(mobile): Google SSO, image resize, Sentry, dark-mode splash (1.0.3-1.0.6)
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>
2026-09-14 21:54:08 +02:00

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);