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:
@@ -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
@@ -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": {},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+720
-303
File diff suppressed because it is too large
Load Diff
+17
-13
@@ -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",
|
||||
|
||||
@@ -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 { 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
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