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:
1 parent
e9bad092f2
commit
99575ddc82
20 files changed
+1219
-415
No files matched your search
@@ -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
|
# 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).
|
# embed client-side (it's the public storage origin, not a service key).
|
||||||
EXPO_PUBLIC_SUPABASE_URL="https://xxxxxxxxxxxx.supabase.co"
|
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
@@ -3,7 +3,7 @@
|
|||||||
"name": "PawFeed",
|
"name": "PawFeed",
|
||||||
"slug": "pawfeed-mobile",
|
"slug": "pawfeed-mobile",
|
||||||
"scheme": "pawfeed",
|
"scheme": "pawfeed",
|
||||||
"version": "1.0.2",
|
"version": "1.0.6",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "org.pawfeed.app",
|
"package": "org.pawfeed.app",
|
||||||
"versionCode": 3,
|
"versionCode": 7,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#fafafa",
|
"backgroundColor": "#fafafa",
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-dev-client",
|
|
||||||
"expo-image",
|
"expo-image",
|
||||||
"expo-system-ui",
|
"expo-system-ui",
|
||||||
[
|
[
|
||||||
@@ -57,9 +56,15 @@
|
|||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"imageWidth": 200,
|
"imageWidth": 200,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#fafafa"
|
"backgroundColor": "#fafafa",
|
||||||
|
"dark": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"backgroundColor": "#111111"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-web-browser",
|
||||||
|
"@sentry/react-native"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"router": {},
|
"router": {},
|
||||||
|
|||||||
@@ -62,11 +62,6 @@ export default function ExploreScreen() {
|
|||||||
numColumns={2}
|
numColumns={2}
|
||||||
columnWrapperStyle={styles.row}
|
columnWrapperStyle={styles.row}
|
||||||
contentContainerStyle={styles.list}
|
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} />}
|
renderItem={({ item }) => <ExplorePetCard pet={item} />}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
@@ -191,10 +186,6 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||||
footer: { marginVertical: 16 },
|
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 },
|
filters: { gap: 10, marginBottom: 12 },
|
||||||
speciesTabs: { gap: 8 },
|
speciesTabs: { gap: 8 },
|
||||||
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import { useTRPC } from "../../src/api/trpc";
|
|||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
import { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
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 { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
|
||||||
import { TextField } from "../../src/components/TextField";
|
import { TextField } from "../../src/components/TextField";
|
||||||
import { Button } from "../../src/components/Button";
|
import { Button } from "../../src/components/Button";
|
||||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
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
|
* Port of web's MilestoneForm — a milestone is a structured Post
|
||||||
* (type=MILESTONE), created for the active pet only. Restricted to the 5
|
* (type=MILESTONE), created for the active pet only. Restricted to the 5
|
||||||
@@ -66,13 +65,13 @@ export default function NewMilestoneScreen() {
|
|||||||
try {
|
try {
|
||||||
let imageKey: string | undefined;
|
let imageKey: string | undefined;
|
||||||
if (asset) {
|
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({
|
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||||
petId: activePetId,
|
petId: activePetId,
|
||||||
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
|
contentType: resized.mimeType,
|
||||||
fileSize: asset.fileSize ?? 0,
|
fileSize: resized.fileSize,
|
||||||
});
|
});
|
||||||
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
|
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||||
imageKey = key;
|
imageKey = key;
|
||||||
}
|
}
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
|
|||||||
@@ -115,11 +115,10 @@ export default function EditPetScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// FlatList, not ScrollView: BreedPicker nests its own vertical
|
// FlatList, not ScrollView — kept from the original nested-ScrollView
|
||||||
// ScrollView, and two same-axis ScrollViews stacked inside each other
|
// fix (see onboarding/pet.tsx's comment); OptionPicker's dropdown now
|
||||||
// silently eat the inner one's scroll gesture on Android — see
|
// renders in a Modal instead, so this is no longer strictly required
|
||||||
// onboarding/pet.tsx's identical fix for the same OptionPicker-based
|
// for that reason, just the established scroll container here.
|
||||||
// component.
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={EMPTY_DATA}
|
data={EMPTY_DATA}
|
||||||
renderItem={null}
|
renderItem={null}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { X, ImagePlus, Camera } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../src/api/trpc";
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||||
|
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||||
import { TextField } from "../../src/components/TextField";
|
import { TextField } from "../../src/components/TextField";
|
||||||
import { Button } from "../../src/components/Button";
|
import { Button } from "../../src/components/Button";
|
||||||
import { colors, radii, typography } from "../../src/theme/tokens";
|
import { colors, radii, typography } from "../../src/theme/tokens";
|
||||||
@@ -56,14 +57,14 @@ export default function NewPostScreen() {
|
|||||||
|
|
||||||
const uploadAsset = useCallback(
|
const uploadAsset = useCallback(
|
||||||
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
||||||
const mimeType = asset.mimeType ?? "image/jpeg";
|
|
||||||
try {
|
try {
|
||||||
|
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||||
petId: activePetId!,
|
petId: activePetId!,
|
||||||
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
|
contentType: resized.mimeType,
|
||||||
fileSize: asset.fileSize ?? 0,
|
fileSize: resized.fileSize,
|
||||||
});
|
});
|
||||||
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
|
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||||
setImages((prev) => {
|
setImages((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[index] = { ...updated[index], status: "done", key };
|
updated[index] = { ...updated[index], status: "done", key };
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import { ImagePlus, Camera } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../src/api/trpc";
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||||
|
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||||
import { Button } from "../../src/components/Button";
|
import { Button } from "../../src/components/Button";
|
||||||
import { colors, radii, typography } from "../../src/theme/tokens";
|
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
|
* 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
|
* 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 () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!asset || !activePetId) return;
|
if (!asset || !activePetId) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
const mimeType = ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "") ? (asset.mimeType as string) : "image/jpeg";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||||
petId: activePetId,
|
petId: activePetId,
|
||||||
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
|
contentType: resized.mimeType,
|
||||||
fileSize: asset.fileSize ?? 0,
|
fileSize: resized.fileSize,
|
||||||
});
|
});
|
||||||
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
|
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||||
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
|
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("onboarding.uploadError"));
|
setError(t("onboarding.uploadError"));
|
||||||
|
|||||||
+127
-7
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { Slot, useRouter, useSegments } from "expo-router";
|
||||||
import { ClerkProvider, useAuth } from "@clerk/expo";
|
import { ClerkProvider, useAuth } from "@clerk/expo";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
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 { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import {
|
import {
|
||||||
useFonts,
|
useFonts,
|
||||||
@@ -17,9 +21,27 @@ import { env } from "../src/lib/env";
|
|||||||
import { TRPCProvider, createMobileTRPCClient, makeQueryClient } from "../src/api/trpc";
|
import { TRPCProvider, createMobileTRPCClient, makeQueryClient } from "../src/api/trpc";
|
||||||
import { queryPersister } from "../src/lib/storage";
|
import { queryPersister } from "../src/lib/storage";
|
||||||
import { ActivePetProvider, useActivePet } from "../src/context/ActivePetContext";
|
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 { 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
|
* Redirects between the (auth) and (app) route groups based on Clerk's
|
||||||
@@ -104,10 +126,101 @@ function OnboardingRedirect() {
|
|||||||
return null;
|
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() {
|
function LoadingScreen() {
|
||||||
|
const systemScheme = useColorScheme();
|
||||||
|
const themeColors = resolveInitialColors(systemScheme);
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }}>
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: themeColors.porchWhite }}>
|
||||||
<ActivityIndicator color={colors.marigoldOrange} />
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,9 +228,10 @@ function LoadingScreen() {
|
|||||||
function InitialLayout() {
|
function InitialLayout() {
|
||||||
const { isLoaded } = useAuth();
|
const { isLoaded } = useAuth();
|
||||||
useProtectedRoute();
|
useProtectedRoute();
|
||||||
|
const authTimedOut = useTimedOut(!isLoaded, AUTH_LOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return <LoadingScreen />;
|
return authTimedOut ? <AuthTimeoutScreen /> : <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,7 +241,7 @@ function InitialLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
function RootLayout() {
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Inter_400Regular,
|
Inter_400Regular,
|
||||||
Inter_600SemiBold,
|
Inter_600SemiBold,
|
||||||
@@ -147,3 +261,9 @@ export default function RootLayout() {
|
|||||||
</ClerkProvider>
|
</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);
|
||||||
@@ -48,13 +48,12 @@ export default function OnboardingPetScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// FlatList, not ScrollView, as the scroll container: PetCreateForm's
|
// FlatList, not ScrollView, as the scroll container — kept from the
|
||||||
// Rassen-Dropdown (OptionPicker) nests its own vertical ScrollView, and
|
// original nested-ScrollView fix (see git history) even though
|
||||||
// two plain same-axis ScrollViews stacked inside each other silently eat
|
// OptionPicker's dropdown now renders in a Modal instead of nesting a
|
||||||
// the inner one's scroll gestures on Android (nestedScrollEnabled does
|
// ScrollView here, so it's no longer strictly required for that
|
||||||
// not reliably fix real-ScrollView-in-ScrollView nesting) — a FlatList
|
// specific reason; still the established, working scroll container for
|
||||||
// outer container is exactly explore.tsx's already-working fix for the
|
// this screen.
|
||||||
// same OptionPicker component (see its ListHeaderComponent usage there).
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={EMPTY_DATA}
|
data={EMPTY_DATA}
|
||||||
renderItem={null}
|
renderItem={null}
|
||||||
|
|||||||
+6
-2
@@ -14,7 +14,9 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://pawfeed.org",
|
"EXPO_PUBLIC_API_URL": "https://pawfeed.org",
|
||||||
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_Y2xlcmsucGF3ZmVlZC5vcmck",
|
"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"
|
"channel": "preview"
|
||||||
},
|
},
|
||||||
@@ -25,7 +27,9 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://pawfeed.org",
|
"EXPO_PUBLIC_API_URL": "https://pawfeed.org",
|
||||||
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_Y2xlcmsucGF3ZmVlZC5vcmck",
|
"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"
|
"channel": "production"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+720
-303
File diff suppressed because it is too large.
Load diff
+17
-13
@@ -5,28 +5,32 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/expo": "^4.6.1",
|
"@clerk/expo": "^4.6.1",
|
||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@sentry/react-native": "~7.11.0",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.102.8",
|
"@tanstack/query-sync-storage-persister": "^5.102.8",
|
||||||
"@tanstack/react-query": "5.102.8",
|
"@tanstack/react-query": "5.102.8",
|
||||||
"@tanstack/react-query-persist-client": "^5.102.8",
|
"@tanstack/react-query-persist-client": "^5.102.8",
|
||||||
"@trpc/client": "11.17.0",
|
"@trpc/client": "11.17.0",
|
||||||
"@trpc/tanstack-react-query": "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-constants": "~57.0.16",
|
||||||
"expo-dev-client": "~57.0.18",
|
"expo-dev-client": "~57.0.19",
|
||||||
"expo-file-system": "~57.0.6",
|
"expo-file-system": "~57.0.6",
|
||||||
"expo-font": "~57.0.3",
|
"expo-font": "~57.0.3",
|
||||||
"expo-image": "~57.0.4",
|
"expo-image": "~57.0.5",
|
||||||
"expo-image-picker": "~57.0.16",
|
"expo-image-manipulator": "~57.0.17",
|
||||||
"expo-linking": "~57.0.9",
|
"expo-image-picker": "~57.0.17",
|
||||||
"expo-localization": "~57.0.1",
|
"expo-linking": "~57.0.10",
|
||||||
"expo-notifications": "~57.0.17",
|
"expo-localization": "~57.0.2",
|
||||||
"expo-router": "~57.0.19",
|
"expo-notifications": "~57.0.18",
|
||||||
"expo-secure-store": "~57.0.3",
|
"expo-router": "~57.0.21",
|
||||||
"expo-splash-screen": "~57.0.8",
|
"expo-secure-store": "~57.0.4",
|
||||||
|
"expo-splash-screen": "~57.0.9",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-system-ui": "~57.0.3",
|
"expo-system-ui": "~57.0.4",
|
||||||
"expo-updates": "~57.0.21",
|
"expo-updates": "~57.0.22",
|
||||||
"expo-video": "~57.0.3",
|
"expo-video": "~57.0.4",
|
||||||
|
"expo-web-browser": "~57.0.3",
|
||||||
"i18next": "^26.4.2",
|
"i18next": "^26.4.2",
|
||||||
"lucide-react-native": "^1.41.0",
|
"lucide-react-native": "^1.41.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Pressable, ScrollView, ActivityIndicator, StyleSheet } from "react-native";
|
import { View, Text, Pressable, ScrollView, ActivityIndicator, Modal, StyleSheet } from "react-native";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
@@ -21,24 +21,32 @@ type Props = {
|
|||||||
|
|
||||||
const BUTTON_HEIGHT = 48;
|
const BUTTON_HEIGHT = 48;
|
||||||
const LIST_GAP = 4;
|
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 —
|
* A dropdown-style button that expands into a scrollable option list —
|
||||||
* extracted from onboarding/PetCreateForm once explore.tsx's breed filter
|
* extracted from onboarding/PetCreateForm once explore.tsx's breed filter
|
||||||
* needed the exact same thing. Deliberately a plain ScrollView + map, not a
|
* needed the exact same thing.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* The option list is position:absolute so it overlays whatever comes after
|
* The option list renders inside a `Modal`, not as a `position: absolute`
|
||||||
* it instead of pushing it down (found.md: the breed dropdown on Explore was
|
* sibling — every consumer embeds this inside an outer FlatList (see
|
||||||
* shoving the pet grid down the screen). zIndex is applied to the whole
|
* onboarding/pet.tsx, edit.tsx, explore.tsx), and having *this* component's
|
||||||
* component root — not just the list — because the sibling content it needs
|
* own vertical ScrollView nested inside that outer FlatList (itself a
|
||||||
* to overlay renders after it in the same parent's paint order, and RN only
|
* VirtualizedList/ScrollView under the hood) silently ate the inner
|
||||||
* lets zIndex reorder stacking among siblings, not push a child above its
|
* ScrollView's touch gesture on Android — `nestedScrollEnabled` is a known
|
||||||
* own subtree's siblings from the inside.
|
* 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({
|
export function OptionPicker({
|
||||||
open,
|
open,
|
||||||
@@ -54,30 +62,62 @@ export function OptionPicker({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
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 (
|
return (
|
||||||
<View style={open ? styles.rootOpen : styles.root}>
|
<View>
|
||||||
<Pressable style={[styles.button, disabled && styles.buttonDisabled]} onPress={onToggle} disabled={disabled}>
|
<Pressable
|
||||||
|
ref={triggerRef}
|
||||||
|
style={[styles.button, disabled && styles.buttonDisabled]}
|
||||||
|
onPress={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
|
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
|
||||||
<ChevronDown color={colors.quietGray} size={18} />
|
<ChevronDown color={colors.quietGray} size={18} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{open && (
|
<Modal visible={open} transparent animationType="fade" onRequestClose={onToggle}>
|
||||||
<View style={styles.list}>
|
{/*
|
||||||
{isLoading ? (
|
Backdrop and list are SIBLINGS, not parent/child. The list must
|
||||||
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
|
not be a descendant of the backdrop Pressable at all — a Pressable
|
||||||
) : options.length === 0 ? (
|
ANYWHERE above a ScrollView in the tree is still a competing
|
||||||
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
|
responder candidate for the scroll gesture, which is exactly why
|
||||||
) : (
|
the previous fix (only de-Pressable-ing the card itself, still
|
||||||
<ScrollView style={styles.scrollView} nestedScrollEnabled>
|
nested inside this backdrop) didn't actually fix the "not
|
||||||
{options.map((option) => (
|
scrollable" report. Keeping them as siblings means a touch inside
|
||||||
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
|
the list's bounds hit-tests into the list's own subtree and never
|
||||||
<Text style={getTypography(colors).body}>{option.name}</Text>
|
reaches the backdrop's Pressable underneath at all.
|
||||||
</Pressable>
|
*/}
|
||||||
))}
|
<Pressable style={styles.backdrop} onPress={onToggle} />
|
||||||
</ScrollView>
|
{anchor && (
|
||||||
)}
|
<View style={[styles.list, { top: anchor.top, left: anchor.left, width: anchor.width }]}>
|
||||||
</View>
|
{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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,8 +125,6 @@ export function OptionPicker({
|
|||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
root: { zIndex: 0 },
|
|
||||||
rootOpen: { zIndex: 30 },
|
|
||||||
button: {
|
button: {
|
||||||
height: BUTTON_HEIGHT,
|
height: BUTTON_HEIGHT,
|
||||||
borderRadius: radii.lg,
|
borderRadius: radii.lg,
|
||||||
@@ -101,15 +139,13 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
buttonDisabled: { opacity: 0.5 },
|
buttonDisabled: { opacity: 0.5 },
|
||||||
value: { ...typography.body },
|
value: { ...typography.body },
|
||||||
placeholder: { ...typography.body, color: colors.quietGray },
|
placeholder: { ...typography.body, color: colors.quietGray },
|
||||||
|
backdrop: { flex: 1 },
|
||||||
list: {
|
list: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: BUTTON_HEIGHT + LIST_GAP,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.hairlineGray,
|
borderColor: colors.hairlineGray,
|
||||||
borderRadius: radii.lg,
|
borderRadius: radii.lg,
|
||||||
maxHeight: 220,
|
maxHeight: MAX_LIST_HEIGHT,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: colors.pureWhite,
|
backgroundColor: colors.pureWhite,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
@@ -118,7 +154,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
},
|
},
|
||||||
scrollView: { maxHeight: 220 },
|
scrollView: { maxHeight: MAX_LIST_HEIGHT },
|
||||||
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||||
loading: { padding: 16 },
|
loading: { padding: 16 },
|
||||||
empty: { ...typography.body, color: colors.quietGray, 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 { ImagePlus, Camera } from "lucide-react-native";
|
||||||
import { useTRPC } from "../../api/trpc";
|
import { useTRPC } from "../../api/trpc";
|
||||||
import { uploadImageToSupabase } from "../../lib/upload";
|
import { uploadImageToSupabase } from "../../lib/upload";
|
||||||
|
import { resizeForUpload } from "../../lib/resize-image";
|
||||||
import { PetAvatar } from "../PetAvatar";
|
import { PetAvatar } from "../PetAvatar";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { colors, radii, typography } from "../../theme/tokens";
|
import { colors, radii, typography } from "../../theme/tokens";
|
||||||
|
|
||||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
petId: string;
|
petId: string;
|
||||||
petName: string;
|
petName: string;
|
||||||
@@ -43,14 +42,14 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
|||||||
setAsset(picked);
|
setAsset(picked);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const mimeType = ALLOWED_MIME_TYPES.includes(picked.mimeType ?? "") ? (picked.mimeType as string) : "image/jpeg";
|
|
||||||
try {
|
try {
|
||||||
|
const resized = await resizeForUpload(picked.uri, picked.width, picked.height);
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||||
petId,
|
petId,
|
||||||
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
|
contentType: resized.mimeType,
|
||||||
fileSize: picked.fileSize ?? 0,
|
fileSize: resized.fileSize,
|
||||||
});
|
});
|
||||||
await uploadImageToSupabase(picked.uri, presignedUrl, mimeType);
|
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||||
await confirmUploadMutation.mutateAsync({ petId, key });
|
await confirmUploadMutation.mutateAsync({ petId, key });
|
||||||
setUploadedKey(key);
|
setUploadedKey(key);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,9 +6,23 @@ import { useTRPC } from "../api/trpc";
|
|||||||
import { storage } from "../lib/storage";
|
import { storage } from "../lib/storage";
|
||||||
import { colors as lightColors, darkColors, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
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;
|
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 = {
|
type ThemeContextValue = {
|
||||||
colors: ColorTokens;
|
colors: ColorTokens;
|
||||||
typography: Typography;
|
typography: Typography;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -17,4 +17,7 @@ export const env = {
|
|||||||
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
|
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||||
),
|
),
|
||||||
supabaseUrl: requireEnv("EXPO_PUBLIC_SUPABASE_URL", process.env.EXPO_PUBLIC_SUPABASE_URL),
|
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,
|
||||||
};
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user