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>
This commit is contained in:
2026-09-14 21:54:08 +02:00
co-authored by Claude Sonnet 5
parent e9bad092f2
commit 99575ddc82
20 changed files with 1219 additions and 415 deletions
+7
View File
@@ -23,3 +23,10 @@ EXPO_PUBLIC_API_URL="https://pawfeed.org"
# value of NEXT_PUBLIC_SUPABASE_URL from the repo root's .env.local. Safe to
# embed client-side (it's the public storage origin, not a service key).
EXPO_PUBLIC_SUPABASE_URL="https://xxxxxxxxxxxx.supabase.co"
# Self-hosted GlitchTip (Sentry-protocol-compatible) DSN — use a separate
# GlitchTip project from the web app's so mobile crashes/breadcrumbs don't
# mix into the same event stream. A DSN is safe to ship client-side (it only
# lets a client send events, not read them). Optional: error reporting is a
# no-op if unset, it doesn't block the app from running.
EXPO_PUBLIC_SENTRY_DSN=""
+10 -5
View File
@@ -3,7 +3,7 @@
"name": "PawFeed",
"slug": "pawfeed-mobile",
"scheme": "pawfeed",
"version": "1.0.2",
"version": "1.0.6",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -13,7 +13,7 @@
},
"android": {
"package": "org.pawfeed.app",
"versionCode": 3,
"versionCode": 7,
"googleServicesFile": "./google-services.json",
"adaptiveIcon": {
"backgroundColor": "#fafafa",
@@ -32,7 +32,6 @@
"expo-router",
"expo-secure-store",
"expo-font",
"expo-dev-client",
"expo-image",
"expo-system-ui",
[
@@ -57,9 +56,15 @@
"image": "./assets/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#fafafa"
"backgroundColor": "#fafafa",
"dark": {
"image": "./assets/splash-icon.png",
"backgroundColor": "#111111"
}
}
]
],
"expo-web-browser",
"@sentry/react-native"
],
"extra": {
"router": {},
-9
View File
@@ -62,11 +62,6 @@ export default function ExploreScreen() {
numColumns={2}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.list}
// FlatList wraps ListHeaderComponent in its own internal container,
// a sibling of the row containers — zIndex on `filters` itself only
// ranks it among ITS OWN children, so the breed dropdown's absolute
// overlay still painted underneath the grid without this.
ListHeaderComponentStyle={styles.headerWrapper}
renderItem={({ item }) => <ExplorePetCard pet={item} />}
onEndReachedThreshold={0.5}
onEndReached={() => {
@@ -191,10 +186,6 @@ function makeStyles(colors: ColorTokens) {
name: { ...typography.body, fontFamily: typography.title.fontFamily },
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
footer: { marginVertical: 16 },
// zIndex above the grid rows below it, so the breed dropdown (position:
// absolute inside OptionPicker) overlays the grid instead of being
// painted behind it — rows have no explicit zIndex (default 0).
headerWrapper: { zIndex: 10 },
filters: { gap: 10, marginBottom: 12 },
speciesTabs: { gap: 8 },
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
+5 -6
View File
@@ -9,13 +9,12 @@ import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { useTheme } from "../../src/context/ThemeContext";
import { uploadImageToSupabase } from "../../src/lib/upload";
import { resizeForUpload } from "../../src/lib/resize-image";
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
import { TextField } from "../../src/components/TextField";
import { Button } from "../../src/components/Button";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
/**
* Port of web's MilestoneForm — a milestone is a structured Post
* (type=MILESTONE), created for the active pet only. Restricted to the 5
@@ -66,13 +65,13 @@ export default function NewMilestoneScreen() {
try {
let imageKey: string | undefined;
if (asset) {
const mimeType = ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "") ? (asset.mimeType as string) : "image/jpeg";
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
fileSize: asset.fileSize ?? 0,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
imageKey = key;
}
createMutation.mutate({
+4 -5
View File
@@ -115,11 +115,10 @@ export default function EditPetScreen() {
}
return (
// FlatList, not ScrollView: BreedPicker nests its own vertical
// ScrollView, and two same-axis ScrollViews stacked inside each other
// silently eat the inner one's scroll gesture on Android — see
// onboarding/pet.tsx's identical fix for the same OptionPicker-based
// component.
// FlatList, not ScrollView — kept from the original nested-ScrollView
// fix (see onboarding/pet.tsx's comment); OptionPicker's dropdown now
// renders in a Modal instead, so this is no longer strictly required
// for that reason, just the established scroll container here.
<FlatList
data={EMPTY_DATA}
renderItem={null}
+5 -4
View File
@@ -8,6 +8,7 @@ import { X, ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { uploadImageToSupabase } from "../../src/lib/upload";
import { resizeForUpload } from "../../src/lib/resize-image";
import { TextField } from "../../src/components/TextField";
import { Button } from "../../src/components/Button";
import { colors, radii, typography } from "../../src/theme/tokens";
@@ -56,14 +57,14 @@ export default function NewPostScreen() {
const uploadAsset = useCallback(
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
const mimeType = asset.mimeType ?? "image/jpeg";
try {
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId!,
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
fileSize: asset.fileSize ?? 0,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "done", key };
+5 -6
View File
@@ -8,11 +8,10 @@ import { ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { uploadImageToSupabase } from "../../src/lib/upload";
import { resizeForUpload } from "../../src/lib/resize-image";
import { Button } from "../../src/components/Button";
import { colors, radii, typography } from "../../src/theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
/**
* Photo-only story creation (matches web's own "Phase 2" note in
* stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload
@@ -65,15 +64,15 @@ export default function NewStoryScreen() {
const handleSubmit = useCallback(async () => {
if (!asset || !activePetId) return;
setError(null);
const mimeType = ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "") ? (asset.mimeType as string) : "image/jpeg";
try {
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
fileSize: asset.fileSize ?? 0,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
} catch {
setError(t("onboarding.uploadError"));
+127 -7
View File
@@ -1,9 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, View } from "react-native";
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,
@@ -17,9 +21,27 @@ 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 { ThemeProvider, useTheme, resolveInitialColors } from "../src/context/ThemeContext";
import { usePushNotifications } from "../src/hooks/usePushNotifications";
import { colors } from "../src/theme/tokens";
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
@@ -104,10 +126,101 @@ function OnboardingRedirect() {
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: colors.porchWhite }}>
<ActivityIndicator color={colors.marigoldOrange} />
<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>
);
}
@@ -115,9 +228,10 @@ function LoadingScreen() {
function InitialLayout() {
const { isLoaded } = useAuth();
useProtectedRoute();
const authTimedOut = useTimedOut(!isLoaded, AUTH_LOAD_TIMEOUT_MS);
if (!isLoaded) {
return <LoadingScreen />;
return authTimedOut ? <AuthTimeoutScreen /> : <LoadingScreen />;
}
return (
@@ -127,7 +241,7 @@ function InitialLayout() {
);
}
export default function RootLayout() {
function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_600SemiBold,
@@ -147,3 +261,9 @@ export default function RootLayout() {
</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);
+6 -7
View File
@@ -48,13 +48,12 @@ export default function OnboardingPetScreen() {
}
return (
// FlatList, not ScrollView, as the scroll container: PetCreateForm's
// Rassen-Dropdown (OptionPicker) nests its own vertical ScrollView, and
// two plain same-axis ScrollViews stacked inside each other silently eat
// the inner one's scroll gestures on Android (nestedScrollEnabled does
// not reliably fix real-ScrollView-in-ScrollView nesting) — a FlatList
// outer container is exactly explore.tsx's already-working fix for the
// same OptionPicker component (see its ListHeaderComponent usage there).
// FlatList, not ScrollView, as the scroll container — kept from the
// original nested-ScrollView fix (see git history) even though
// OptionPicker's dropdown now renders in a Modal instead of nesting a
// ScrollView here, so it's no longer strictly required for that
// specific reason; still the established, working scroll container for
// this screen.
<FlatList
data={EMPTY_DATA}
renderItem={null}
+6 -2
View File
@@ -14,7 +14,9 @@
"env": {
"EXPO_PUBLIC_API_URL": "https://pawfeed.org",
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_Y2xlcmsucGF3ZmVlZC5vcmck",
"EXPO_PUBLIC_SUPABASE_URL": "https://gqhsovpcfgffdtjxumyy.supabase.co"
"EXPO_PUBLIC_SUPABASE_URL": "https://gqhsovpcfgffdtjxumyy.supabase.co",
"EXPO_PUBLIC_SENTRY_DSN": "https://662a63ad7d514aa8830de40bd90fcb68@glitchtip.pawfeed.org/2",
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
},
"channel": "preview"
},
@@ -25,7 +27,9 @@
"env": {
"EXPO_PUBLIC_API_URL": "https://pawfeed.org",
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_Y2xlcmsucGF3ZmVlZC5vcmck",
"EXPO_PUBLIC_SUPABASE_URL": "https://gqhsovpcfgffdtjxumyy.supabase.co"
"EXPO_PUBLIC_SUPABASE_URL": "https://gqhsovpcfgffdtjxumyy.supabase.co",
"EXPO_PUBLIC_SENTRY_DSN": "https://662a63ad7d514aa8830de40bd90fcb68@glitchtip.pawfeed.org/2",
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
},
"channel": "production"
}
+720 -303
View File
File diff suppressed because it is too large Load Diff
+17 -13
View File
@@ -5,28 +5,32 @@
"dependencies": {
"@clerk/expo": "^4.6.1",
"@expo-google-fonts/inter": "^0.4.2",
"@sentry/react-native": "~7.11.0",
"@tanstack/query-sync-storage-persister": "^5.102.8",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-query-persist-client": "^5.102.8",
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.20",
"expo": "~57.0.22",
"expo-auth-session": "~57.0.12",
"expo-constants": "~57.0.16",
"expo-dev-client": "~57.0.18",
"expo-dev-client": "~57.0.19",
"expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
"expo-image": "~57.0.4",
"expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.9",
"expo-localization": "~57.0.1",
"expo-notifications": "~57.0.17",
"expo-router": "~57.0.19",
"expo-secure-store": "~57.0.3",
"expo-splash-screen": "~57.0.8",
"expo-image": "~57.0.5",
"expo-image-manipulator": "~57.0.17",
"expo-image-picker": "~57.0.17",
"expo-linking": "~57.0.10",
"expo-localization": "~57.0.2",
"expo-notifications": "~57.0.18",
"expo-router": "~57.0.21",
"expo-secure-store": "~57.0.4",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"expo-updates": "~57.0.21",
"expo-video": "~57.0.3",
"expo-system-ui": "~57.0.4",
"expo-updates": "~57.0.22",
"expo-video": "~57.0.4",
"expo-web-browser": "~57.0.3",
"i18next": "^26.4.2",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
+25
View File
@@ -0,0 +1,25 @@
import Svg, { Path } from "react-native-svg";
/** Standard four-color Google "G" mark per Google's sign-in button branding guidelines. */
export function GoogleIcon({ size = 18 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 18 18">
<Path
fill="#4285F4"
d="M17.64 9.2045c0-.6381-.0573-1.2518-.1636-1.8409H9v3.4814h4.8436c-.2086 1.125-.8427 2.0782-1.7959 2.7164v2.2581h2.9087c1.7018-1.5668 2.6836-3.874 2.6836-6.615z"
/>
<Path
fill="#34A853"
d="M9 18c2.43 0 4.4673-.806 5.9564-2.1805l-2.9087-2.2581c-.8059.5404-1.8368.8591-3.0477.8591-2.3446 0-4.3282-1.5831-5.036-3.7104H.9573v2.3318C2.4382 15.9832 5.4818 18 9 18z"
/>
<Path
fill="#FBBC05"
d="M3.964 10.71c-.18-.5404-.2827-1.1177-.2827-1.71s.1027-1.1696.2827-1.71V4.9582H.9573C.3477 6.1732 0 7.5477 0 9s.3477 2.8268.9573 4.0418L3.964 10.71z"
/>
<Path
fill="#EA4335"
d="M9 3.5795c1.3214 0 2.5077.4541 3.4405 1.346l2.5813-2.5814C13.4632.8918 11.4259 0 9 0 5.4818 0 2.4382 2.0168.9573 4.9582L3.964 7.29C4.6718 5.1627 6.6555 3.5795 9 3.5795z"
/>
</Svg>
);
}
@@ -0,0 +1,126 @@
import { useMemo, useState } from "react";
import { Pressable, Text, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useSSO } from "@clerk/expo";
import * as Sentry from "@sentry/react-native";
import { useTheme } from "../context/ThemeContext";
import { useWarmUpBrowser } from "../hooks/useWarmUpBrowser";
import { radii, fontFamily, type ColorTokens } from "../theme/tokens";
import { GoogleIcon } from "./GoogleIcon";
type Props = {
onError: (message: string) => void;
/**
* Whether the caller already has affirmative legal consent to attach if
* Clerk reports it as a missing requirement (see below). Pass the ticked
* checkbox value from sign-up.tsx; omit on sign-in.tsx, where there's no
* consent UI to source it from.
*/
legalAccepted?: boolean;
/**
* Called instead of completing the flow when Google authenticates someone
* with no existing PawFeed account and no `legalAccepted` was supplied —
* i.e. sign-in.tsx was used to (attempt to) create a brand-new account.
* Required on sign-in.tsx; sign-up.tsx doesn't need it because it gates
* the button on its own checkbox instead (see its `disabled` usage).
*/
onNeedsConsent?: () => void;
disabled?: boolean;
};
/**
* "Continue with Google" — shared by sign-in.tsx and sign-up.tsx since
* Clerk's SSO flow creates a session (and a new account, if none exists yet)
* in one step; there's no separate sign-up variant to build.
*
* Uses the stable `useSSO()` from `@clerk/expo` (not `/experimental`), the
* import path already used for useSignIn/useSignUp elsewhere in this app —
* see AGENTS.md on why this project's installed Clerk version puts its
* Futures-style API in the stable package rather than under `/experimental`.
*
* This Clerk instance requires legal consent at sign-up
* (`sign_up.legal_consent_enabled: true`, verified live via its public
* /v1/environment config — same check sign-up.tsx's own comment describes
* for the password flow). Google's transfer-based account creation inside
* useSSO() has no field for it, so a brand-new Google sign-up otherwise gets
* stuck on `missing_requirements` exactly like the password flow did before
* that fix — handled below via `signUp.update({ legalAccepted: true })`
* once we know consent was actually given (legalAccepted param truthy).
*/
export function GoogleSignInButton({ onError, legalAccepted, onNeedsConsent, disabled }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const router = useRouter();
const { startSSOFlow } = useSSO();
const [isPending, setIsPending] = useState(false);
useWarmUpBrowser();
const onPress = async () => {
setIsPending(true);
try {
const ssoResult = await startSSOFlow({ strategy: "oauth_google" });
const { setActive, signUp } = ssoResult;
let createdSessionId = ssoResult.createdSessionId;
if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) {
if (!legalAccepted) {
onNeedsConsent?.();
return;
}
const updatedSignUp = await signUp.update({ legalAccepted: true });
createdSessionId = updatedSignUp.createdSessionId;
}
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId });
router.replace("/(app)");
}
// No createdSessionId otherwise (e.g. the user cancelled the browser
// flow) — nothing to do, they're still looking at this screen.
} catch (err) {
// Previously only shown locally — a redirect/OAuth failure a tester
// hits and can't reproduce left no trace anywhere (found.md: "redirect
// problem beim Login mit Google, konnte ich nicht reproduzieren").
Sentry.captureException(err);
onError(err instanceof Error ? err.message : t("auth.googleSignInError"));
} finally {
setIsPending(false);
}
};
return (
<Pressable
style={(state) => [
styles.base,
state.pressed && !isPending && !disabled && styles.pressed,
(isPending || disabled) && styles.disabled,
]}
disabled={isPending || disabled}
onPress={onPress}
>
<GoogleIcon size={18} />
<Text style={styles.text}>{t("auth.googleButton")}</Text>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
return StyleSheet.create({
base: {
height: 48,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
},
pressed: { transform: [{ translateY: 1 }], opacity: 0.92 },
disabled: { opacity: 0.5 },
text: { color: colors.ink, fontFamily: fontFamily.semiBold, fontSize: 14 },
});
}
+77 -41
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { View, Text, Pressable, ScrollView, ActivityIndicator, StyleSheet } from "react-native";
import { useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, ScrollView, ActivityIndicator, Modal, StyleSheet } from "react-native";
import { ChevronDown } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../context/ThemeContext";
@@ -21,24 +21,32 @@ type Props = {
const BUTTON_HEIGHT = 48;
const LIST_GAP = 4;
const MAX_LIST_HEIGHT = 220;
type Anchor = { top: number; left: number; width: number };
/**
* A dropdown-style button that expands into a scrollable option list —
* extracted from onboarding/PetCreateForm once explore.tsx's breed filter
* needed the exact same thing. Deliberately a plain ScrollView + map, not a
* FlatList: this is meant to sit inside a screen-level ScrollView, and
* nesting a VirtualizedList inside a ScrollView with the same orientation
* breaks touch/scroll handling (RN's "VirtualizedLists should never be
* nested..." warning) — these option lists are at most a few dozen items,
* so virtualization buys nothing here anyway.
* needed the exact same thing.
*
* The option list is position:absolute so it overlays whatever comes after
* it instead of pushing it down (found.md: the breed dropdown on Explore was
* shoving the pet grid down the screen). zIndex is applied to the whole
* component root — not just the list — because the sibling content it needs
* to overlay renders after it in the same parent's paint order, and RN only
* lets zIndex reorder stacking among siblings, not push a child above its
* own subtree's siblings from the inside.
* The option list renders inside a `Modal`, not as a `position: absolute`
* sibling — every consumer embeds this inside an outer FlatList (see
* onboarding/pet.tsx, edit.tsx, explore.tsx), and having *this* component's
* own vertical ScrollView nested inside that outer FlatList (itself a
* VirtualizedList/ScrollView under the hood) silently ate the inner
* ScrollView's touch gesture on Android — `nestedScrollEnabled` is a known
* pain point (Android-only, notoriously unreliable across RN/Android
* versions) rather than a real fix. A Modal renders into its own native
* layer entirely outside the JS view/scroll hierarchy, so this sidesteps
* the nested-same-axis-scroll problem structurally instead of working
* around it — found.md: reported again on Android 17 (Pixel 10 Pro XL)
* after the nestedScrollEnabled-based fix.
*
* Since a Modal covers the whole screen by default, the trigger button's
* on-screen position is measured (measureInWindow) when opening and used to
* position the option list right under the button, keeping the same
* inline-dropdown look the absolute-positioned version had.
*/
export function OptionPicker({
open,
@@ -54,30 +62,62 @@ export function OptionPicker({
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const triggerRef = useRef<View>(null);
const [anchor, setAnchor] = useState<Anchor | null>(null);
useEffect(() => {
// No need to reset `anchor` to null when closing — the Modal already
// isn't `visible` then, and this effect re-measures before anything
// renders with it on the next open, so a stale value in between is
// never actually seen.
if (!open) return;
triggerRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({ top: y + height + LIST_GAP, left: x, width });
});
}, [open]);
return (
<View style={open ? styles.rootOpen : styles.root}>
<Pressable style={[styles.button, disabled && styles.buttonDisabled]} onPress={onToggle} disabled={disabled}>
<View>
<Pressable
ref={triggerRef}
style={[styles.button, disabled && styles.buttonDisabled]}
onPress={onToggle}
disabled={disabled}
>
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
<ChevronDown color={colors.quietGray} size={18} />
</Pressable>
{open && (
<View style={styles.list}>
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
) : options.length === 0 ? (
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
) : (
<ScrollView style={styles.scrollView} nestedScrollEnabled>
{options.map((option) => (
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
<Text style={getTypography(colors).body}>{option.name}</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
)}
<Modal visible={open} transparent animationType="fade" onRequestClose={onToggle}>
{/*
Backdrop and list are SIBLINGS, not parent/child. The list must
not be a descendant of the backdrop Pressable at all — a Pressable
ANYWHERE above a ScrollView in the tree is still a competing
responder candidate for the scroll gesture, which is exactly why
the previous fix (only de-Pressable-ing the card itself, still
nested inside this backdrop) didn't actually fix the "not
scrollable" report. Keeping them as siblings means a touch inside
the list's bounds hit-tests into the list's own subtree and never
reaches the backdrop's Pressable underneath at all.
*/}
<Pressable style={styles.backdrop} onPress={onToggle} />
{anchor && (
<View style={[styles.list, { top: anchor.top, left: anchor.left, width: anchor.width }]}>
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
) : options.length === 0 ? (
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
) : (
<ScrollView style={styles.scrollView}>
{options.map((option) => (
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
<Text style={getTypography(colors).body}>{option.name}</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
)}
</Modal>
</View>
);
}
@@ -85,8 +125,6 @@ export function OptionPicker({
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
root: { zIndex: 0 },
rootOpen: { zIndex: 30 },
button: {
height: BUTTON_HEIGHT,
borderRadius: radii.lg,
@@ -101,15 +139,13 @@ function makeStyles(colors: ColorTokens) {
buttonDisabled: { opacity: 0.5 },
value: { ...typography.body },
placeholder: { ...typography.body, color: colors.quietGray },
backdrop: { flex: 1 },
list: {
position: "absolute",
top: BUTTON_HEIGHT + LIST_GAP,
left: 0,
right: 0,
borderWidth: 1,
borderColor: colors.hairlineGray,
borderRadius: radii.lg,
maxHeight: 220,
maxHeight: MAX_LIST_HEIGHT,
overflow: "hidden",
backgroundColor: colors.pureWhite,
elevation: 8,
@@ -118,7 +154,7 @@ function makeStyles(colors: ColorTokens) {
shadowOpacity: 0.15,
shadowRadius: 8,
},
scrollView: { maxHeight: 220 },
scrollView: { maxHeight: MAX_LIST_HEIGHT },
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
loading: { padding: 16 },
empty: { ...typography.body, color: colors.quietGray, padding: 16 },
@@ -6,12 +6,11 @@ import { useMutation } from "@tanstack/react-query";
import { ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { uploadImageToSupabase } from "../../lib/upload";
import { resizeForUpload } from "../../lib/resize-image";
import { PetAvatar } from "../PetAvatar";
import { Button } from "../Button";
import { colors, radii, typography } from "../../theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
type Props = {
petId: string;
petName: string;
@@ -43,14 +42,14 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
setAsset(picked);
setError(null);
const mimeType = ALLOWED_MIME_TYPES.includes(picked.mimeType ?? "") ? (picked.mimeType as string) : "image/jpeg";
try {
const resized = await resizeForUpload(picked.uri, picked.width, picked.height);
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId,
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
fileSize: picked.fileSize ?? 0,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(picked.uri, presignedUrl, mimeType);
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
await confirmUploadMutation.mutateAsync({ petId, key });
setUploadedKey(key);
} catch {
+15 -1
View File
@@ -6,9 +6,23 @@ import { useTRPC } from "../api/trpc";
import { storage } from "../lib/storage";
import { colors as lightColors, darkColors, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
const THEME_PREFERENCE_KEY = "theme_preference";
export const THEME_PREFERENCE_KEY = "theme_preference";
type ThemePreference = "LIGHT" | "DARK" | null;
/**
* Same "local guess" logic ThemeProvider uses for its first-frame paint,
* exposed standalone so screens that render before ThemeProvider mounts
* (app/_layout.tsx's LoadingScreen, shown pre-auth/pre-fonts) can still
* pick the right palette instead of hardcoding the light one — the cause of
* a reported black-on-black flash right after login for a dark-mode user.
*/
export function resolveInitialColors(systemScheme: string | null | undefined): ColorTokens {
const stored = storage.getString(THEME_PREFERENCE_KEY);
const preference: ThemePreference = stored === "LIGHT" || stored === "DARK" ? stored : null;
const isDark = preference === "DARK" || (preference === null && systemScheme === "dark");
return isDark ? darkColors : lightColors;
}
type ThemeContextValue = {
colors: ColorTokens;
typography: Typography;
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from "react";
import * as WebBrowser from "expo-web-browser";
/**
* Pre-warms the Android Custom Tab / iOS SFSafariViewController so the
* Google OAuth browser session opens without a cold-start delay — Clerk's
* documented pattern for useSSO(). No-op cleanup on unmount avoids leaking
* the warmed instance once the sign-in/sign-up screen is left.
*/
export function useWarmUpBrowser() {
useEffect(() => {
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
}, []);
}
+3
View File
@@ -17,4 +17,7 @@ export const env = {
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
),
supabaseUrl: requireEnv("EXPO_PUBLIC_SUPABASE_URL", process.env.EXPO_PUBLIC_SUPABASE_URL),
// Optional, not requireEnv() — error reporting should degrade to a no-op
// when unset (local dev without a DSN configured), not crash the app.
sentryDsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
};
+39
View File
@@ -0,0 +1,39 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
import { File } from "expo-file-system";
const MAX_DIMENSION = 1600;
const COMPRESS_QUALITY = 0.8;
export type ResizedImage = {
uri: string;
mimeType: "image/jpeg";
fileSize: number;
};
/**
* Downscales a picked photo to a feed-appropriate resolution before upload.
* expo-image-picker's `quality: 0.8` only re-encodes at the camera's native
* resolution (often 3000-4000px wide) — every future viewer then downloads
* that full-size original just to display it at card width. Supabase's
* Image Transform API would resize on read instead, but that's a Pro-plan
* feature this project doesn't have, so the fix has to happen once here, at
* write time.
*
* Always re-encodes to JPEG regardless of the source format — these are
* photos, not graphics that need PNG's lossless/alpha behavior, and JPEG
* compresses substantially further at an equivalent visual quality.
*/
export async function resizeForUpload(uri: string, width: number, height: number): Promise<ResizedImage> {
const needsResize = width > 0 && height > 0 && Math.max(width, height) > MAX_DIMENSION;
const context = ImageManipulator.manipulate(uri);
const withResize = needsResize
? width >= height
? context.resize({ width: MAX_DIMENSION })
: context.resize({ height: MAX_DIMENSION })
: context;
const rendered = await withResize.renderAsync();
const result = await rendered.saveAsync({ compress: COMPRESS_QUALITY, format: SaveFormat.JPEG });
return { uri: result.uri, mimeType: "image/jpeg", fileSize: new File(result.uri).size };
}