Files
adminandClaude Sonnet 5 9185e54402 fix(mobile): dark-mode white-on-white caption text in New Post
post-new.tsx (and story-new.tsx, same latent bug) still imported the
static, light-only colors/typography from theme/tokens.ts instead of
useTheme() - container background stayed permanently light while the
shared TextField correctly pulled dark-mode text color from the theme,
producing white text on a white background in the caption input.

Migrated both to the useTheme() + makeStyles(colors) pattern already
used by milestone-new.tsx. search.tsx has the same underlying issue but
needs a larger refactor (several sub-components reference the static
tokens directly) - left for a separate pass, noted in found.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 22:25:08 +02:00
..

PawFeed Mobile

Native Android/iOS client for PawFeed, built with React Native + Expo (Expo Router). It talks to the exact same backend the web app uses — the self-hosted Docker deployment described in ../docker/SCALING-ROADMAP.md — over the existing tRPC API. No backend/Docker changes were needed to make this possible, see "Why no backend changes" below.

Decided over Capacitor (see the now-superseded ../mobil.md) because a native RN app gives better performance/feel and can still share real logic with a future web-shared package, at the cost of more upfront work — see the architecture-decision thread in this session's handoff.

Status: Phase 1 — real Feed/Explore/Notifications/Pets tabs against the live backend, not just a connectivity scaffold anymore. Post creation, comments, repost, pet profile detail (view/follow/edit/followers-following), stories (tray/creation/viewer), video playback, search, health tracking (consent gate + weight/vet/vaccine logs), milestones, and message sending/receiving are done and verified end-to-end against the live backend. Push notifications are fully working — see "Push notifications" below. Sign-up, first-pet onboarding, a light/dark/auto theme, and German/English i18n are also done — see their own sections below. Android is the priority platform; iOS builds need a Mac + Apple Developer account and come later (same codebase, no separate source tree — see "Android-first, then iOS" below).

Architecture

  • Routing: Expo Router (file-based, app/). (auth) and (app) are route groups; app/_layout.tsx redirects between them based on Clerk's session state (useProtectedRoute), mirroring Clerk's official expo-router integration pattern.
  • Auth: @clerk/expo (the current SDK — @clerk/clerk-expo is deprecated, don't reinstall it). Session JWT persists in the OS keystore via expo-secure-store (src/lib/tokenCache.ts) instead of Clerk's in-memory default. Sign-in and sign-up (app/(auth)/sign-in.tsx, sign-up.tsx) are both custom screens using Clerk's "Futures" API (signIn.password()/signUp.password() + verifications/finalize()) rather than the pre-built <SignIn/>/<SignUp/> web components, which don't render in React Native. Sign-up has no invite-code step — unlike web's /join gate, INVITE_REQUIRED is globally off on the backend since launch.
  • API: src/api/trpc.ts creates a @trpc/client pointed at ${EXPO_PUBLIC_API_URL}/api/trpc, with the Clerk session token attached as Authorization: Bearer <token> on every request (useAuth().getToken, called per-request so a refreshed token is always used).
  • Type safety: AppRouter is imported as a type-only import directly from the web app's ../src/trpc/routers/_app.ts — no code duplication, no publishing a package. This works because mobile/ is nested inside the same repo: Node's (and TypeScript's) module resolution walks up to the root node_modules for @trpc/server, zod, etc., so the type import resolves without installing those packages a second time here. If a future refactor turns this repo into an npm workspace, revisit this — it wasn't necessary for the initial scaffold. src/api/types.ts centralizes the inferRouterOutputs<AppRouter> types every screen uses (Pet, FeedPost, etc.) instead of repeating the long relative import.
  • Design system: src/theme/tokens.ts mirrors the web app's DESIGN.md ("Spotlight Porch" — marigold-orange accent, porch-white canvas, Inter font, hairline rings instead of shadows). src/components/ has the shared building blocks (Button, TextField, Card) plus feed-specific ports of the web equivalents (PostCard, PawButton, PetAvatar, PetIdentityBadges).
  • Active pet: every pet-scoped procedure (feed.getFeed, reactions.toggle, notifications.list, ...) takes an explicit petId and asserts ownership server-side — there's no server-side "current pet". src/context/ActivePetContext.tsx is mobile's equivalent of web's ActivePetContext (MMKV-backed instead of localStorage), defaulting to the owner's first pet; the Pets tab doubles as the pet switcher.
  • Fast reopen: the TanStack Query cache is persisted to react-native-mmkv (src/lib/storage.ts, wired via PersistQueryClientProvider in app/_layout.tsx) so the last-known feed/pets/notifications render immediately on relaunch while a background refetch reconciles them — no blank screen while waiting on the network. Images go through expo-image for the same reason (disk-cached, unlike RN's built-in Image).
  • Navigation: expo-router's built-in <Tabs> (app/(app)/(tabs)/_layout.tsx) — Feed/Explore/Notifications/Pets, mirroring the web app's actual mobile bottom nav (src/components/layout/MobileNav.tsx), not an invented IA. Messages is a header icon button pushing app/(app)/messages/index.tsx, matching web (there it's a floating corner button, not a tab).

Why no backend changes were needed

Two things had to be true for the app to reach the Docker-hosted backend as-is, and both already were:

  1. CORS doesn't apply. CORS is a browser same-origin mechanism; a native app's fetch isn't a browser context, so the "just needs to reach it" requirement was already satisfied by the API being reachable over HTTPS at pawfeed.org.
  2. Clerk already accepts bearer tokens. src/proxy.ts already marks /api/trpc/(.*) as a public route at the middleware level (tRPC does its own per-procedure auth — see the comment there), and auth() in src/trpc/init.ts recognizes both the browser's session cookie AND an Authorization: Bearer JWT out of the box. Native/Expo apps sending a bearer token is Clerk's documented mobile auth flow, not a special case that needed enabling server-side.

Setup

Runs as a custom dev client, not Expo Go. @clerk/expo's Device Trust flow (see below) and react-native-mmkv both need native code Expo Go doesn't ship, so this app builds and installs its own native shell locally — no EAS account/login needed, everything runs on-device via the Android SDK already required for the emulator.

cd mobile
npm install
cp .env.example .env   # already done locally with the live Clerk publishable key

# First time only (or after adding a native dependency / editing app.json):
npx expo prebuild --platform android
JAVA_HOME="<path to a JDK 17 install>" npx expo run:android

JAVA_HOME matters: RN 0.86's Gradle build needs JDK 17, and a JDK that's too new (e.g. a bundled Android Studio JBR on JDK 21+) fails native module CMake configuration with a cryptic "restricted method... System" error. Eclipse Temurin 17 works (winget install EclipseAdoptium.Temurin.17.JDK on Windows).

Once the native app is installed, day-to-day iteration doesn't need another expo run:android — just npm start (→ expo start --dev-client) and reopen the already-installed app; that command only bundles/serves JS.

Requires an Android emulator (Android Studio) or a physical device. See https://docs.expo.dev/versions/v57.0.0/ for current Expo APIs — SDK 57 changed things vs. older Expo docs/training data, per AGENTS.md in this folder.

Clerk dashboard: Native Applications API

Clerk's "Device Trust" feature (tied to the dashboard's Native Applications API) makes signIn.status come back as needs_client_trust on a sign-in from a new device — resolved via signIn.mfa.sendEmailCode() / verifyEmailCode() in app/(auth)/sign-in.tsx, a one-time email-code check per device, independent of any account-level MFA setting. If sign-in hangs at "Clerk lädt…" forever with no error, or signIn.status never reaches complete, check that the Native Applications API is enabled for this Clerk instance.

.env

Var Purpose
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY Same Clerk instance as the web app — copy from root .env.local's NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. Safe to embed client-side (that's what "publishable" means).
EXPO_PUBLIC_API_URL Backend origin. Defaults to https://pawfeed.org (the live deployment) — see the LAN-IP / Android-emulator alternatives in .env.example for local dev against npm run dev instead.
EXPO_PUBLIC_SUPABASE_URL Same Supabase project as the web app — copy from root .env.local's NEXT_PUBLIC_SUPABASE_URL. Used to build public media URLs (src/lib/media-url.ts); safe client-side, it's the public storage origin, not a service key.

Caution: the default .env points at the live production backend and database — the same one real users are on (project_prod_launch_2026-08-10). Reading (public stats, browsing) is harmless. Anything that writes data while iterating on mobile features should point EXPO_PUBLIC_API_URL at a local npm run dev instead (http://10.0.2.2:3000 from the Android emulator, or the dev machine's LAN IP from a physical device).

Folder structure

mobile/
  app/                    # expo-router routes
    _layout.tsx            # ClerkProvider + persisted QueryClient/tRPC + ActivePetProvider + auth redirect
    (auth)/
      sign-in.tsx           # email/password + Clerk Device Trust email-code step
      sign-up.tsx            # email/password + Clerk email-verification-code step, no invite code
    (app)/
      _layout.tsx           # Stack hosting the (tabs) group + the messages/search/story/comment screens
      (tabs)/
        _layout.tsx         # bottom Tabs: Feed/Explore/Notifications/Pets + header Search/Messages buttons
        index.tsx           # Feed (StoryTray header + infinite post list)
        explore.tsx         # pet discovery grid
        notifications.tsx   # notifications list
        pets.tsx             # owner's pets + active-pet switcher
      messages/index.tsx    # conversations list
      messages/[conversationId].tsx  # chat detail/compose, polls messages.list while focused (no realtime)
      search.tsx             # pets/people/hashtags search, three tabs over one debounced query
      post-new.tsx           # create a photo post (pick/upload → posts.create)
      story-new.tsx           # create a photo story (pick/upload → stories.create)
      story-viewer.tsx        # full-screen story playback (tap zones, progress bars, view tracking)
      milestone-new.tsx        # create a milestone post (type picker + optional photo → milestones.create)
      pets/[petId]/health/index.tsx  # weight/vet/vaccine logs, gated by HealthConsentGate
      pets/[petId]/milestones.tsx    # a pet's milestone posts (milestones.listByPet)
      posts/[postId]/comments.tsx  # read/write/delete comments for a post
      image-viewer.tsx        # full-screen tapped-image lightbox
    onboarding/
      _layout.tsx             # Stack with a close-to-/(app) button, shared by all onboarding steps
      pet.tsx                  # 5-step flow: guidelines → upload consent → spider opt-in → pet form → avatar
  src/
    api/trpc.ts             # tRPC client, AppRouter type import, query client factory
    api/types.ts             # inferRouterOutputs<AppRouter> shortcuts (Pet, FeedPost, ...)
    context/ActivePetContext.tsx  # MMKV-backed active-pet state
    context/ThemeContext.tsx  # light/dark/auto preference (owner.getThemePreference, see "Dark mode")
    hooks/useDebounce.ts      # generic debounce hook (search input)
    hooks/usePushNotifications.ts  # permission + Expo push token registration (owner.registerPushToken)
    lib/env.ts               # typed EXPO_PUBLIC_* access
    lib/tokenCache.ts        # Clerk session persistence via expo-secure-store
    lib/storage.ts            # MMKV instance + TanStack Query sync persister
    lib/media-url.ts          # Supabase public storage URL builder
    lib/format.ts             # pet display name + relative-time formatting
    lib/sponsor.ts             # active-sponsor check
    lib/upload.ts              # direct PUT of a picked image to a presigned Supabase URL
    lib/milestone-meta.ts      # MilestoneType → i18n key/icon map, see "Internationalization"
    theme/tokens.ts            # design tokens (light `colors` + dark `darkColors`), see "Dark mode"
    i18n/index.ts               # i18next setup, device-locale detection, language preference, see "Internationalization"
    i18n/locales/{en,de}.json    # translation catalogs (kept in exact key-parity, see "Internationalization")
    components/                 # Button, TextField, Card, PostCard, PawButton, PetAvatar,
                                 # PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer,
                                 # OptionPicker (species/breed dropdown, shared with Explore + onboarding)
    components/health/          # HealthConsentGate, WeightLogSection, VetVisitSection, VaccineSection
    components/onboarding/      # OnboardingGuidelines, UploadConsent, ArachnophobiaOptIn, PetCreateForm, AvatarStep

Android-first, then iOS

Both platforms build from this one Expo codebase (expo run:android / expo run:ios) — there's no separate iOS source tree to scaffold separately. "Starting with Android" means:

  • app.json's android.package (org.pawfeed.app) is set now; ios.bundleIdentifier is set to the same reverse-DNS id but untested.

  • Android needs a Google Play Console account (~25 € one-time, already budgeted in ../mobil.md) before an internal test track can be created.

  • iOS needs a Mac (for expo run:ios / EAS builds with credentials) and an Apple Developer account (99 €/year) — deferred until Android is validated.

  • EAS Build (eas.json) is the path used for producing signed Android binaries — no local Android Studio toolchain needed. The app is live on Google Play (closed testing, since 2026-09-08); iOS is still deferred. Build a new production .aab:

    npx eas-cli build --platform android --profile production
    

    Bump app.json's version and android.versionCode first — Play Console rejects a re-upload with an unchanged versionCode.

Shipping a fix without a new Play Store review (EAS Update)

expo-updates is configured (runtimeVersion policy "appVersion", per-profile channels in eas.json) — see Gitea #35. A pure JS/UI fix (no new native dependency, no app.json native-config change: no new permission, icon, plugin, etc.) can go out as an over-the-air update instead of a new store build/review:

npx eas-cli update --branch production --message "short description"

Installed apps built from the production profile check for and apply this automatically on next launch (expo-updates's default ON_LOAD behavior — no extra code needed). The update's runtimeVersion must match the installed build's version string exactly, so this only works between builds that share the same app.json version — bumping version for a native-code build starts a new runtime-version lineage that old OTA updates won't reach and that needs its own store review.

What's not built yet

Phase 1 (Feed/Explore/Notifications/Pets tabs, reactions, active-pet switching, persisted cache for fast reopen) plus a full pet profile (view/follow/edit/followers-following), post creation/upload, comments, repost ("Paw-Back") creation, stories (tray/creation/viewer via expo-video-adjacent expo-image, plus PawRing seen/unseen indicator), video playback (expo-video against Mux HLS, polling while PROCESSING), search (pets/people/hashtags, search.pets/byOwner/byHashtag), health tracking (consent gate + weight/vet visit/vaccine logs — feeding info, emergency vet, and the shareable Health Card are web-only for now), milestones (view + create, restricted to the 5 user-initiated types), and messaging (conversation list + chat detail/compose, polling while the screen is focused since messages.ts has no realtime mechanism) are done and verified end-to-end against the live backend on a real Android emulator. Still missing, roughly in the order they'd unblock the most:

  • Web's Explore "Trending" hashtags — mobile has no hashtag search/browse UI at all. The trending posts half of that tab isn't a separate mobile screen either, but its data (explore.getTrending) is now surfaced by mixing a few of those posts into the Feed itself at semi-random intervals (src/lib/interleave-trending.ts, marked with a small "Trending" badge on PostCard) rather than building a second tab/grid — see the Feed section of "Folder structure" above for where that lives.

Onboarding (/onboarding/pet — guidelines → upload consent → Arachnophobie-Helfer opt-in → pet form → avatar), sign-up, dark mode, and i18n are now built — see their own sections below.

Onboarding and pet management

app/onboarding/pet.tsx ports web's five-step onboarding (src/app/onboarding/pet/page.tsx) exactly: community guidelines (legal.getPendingUpdate/acknowledge, auto-skips if nothing's been published) → upload-rights + data-processing consent (legal.getUploadConsentStatus/acknowledgeUploadConsent, auto-skips if already given) → Arachnophobie-Helfer opt-in (owner.setHideSpidersPreference, never blocks) → pet form (name/nickname/species/breed/bio/adoption story, src/components/onboarding/PetCreateForm.tsx) → avatar photo (src/components/onboarding/AvatarStep.tsx, skippable). Web reuses this same route for "add another pet" from an existing account (not just first-time onboarding) — mobile does too, via the Pets tab's + header button (app/(app)/(tabs)/pets.tsx).

  • OnboardingRedirect (app/_layout.tsx) sends any signed-in owner with zero pets to /onboarding/pet, mirroring web's (app)/layout.tsx redirect. It gates on useActivePet()'s isPending (not isLoading) — isLoading is false while the pets query is still enabled: false (before Clerk confirms isSignedIn), which made pets default to [] and look like "no pets" long enough to fire a router.replace loop hard enough to hit React's "Maximum update depth exceeded" on cold start. isPending stays true through that whole window instead.
  • Species/breed pickers (src/components/OptionPicker.tsx, shared with Explore's breed filter) are a plain ScrollView + .map(), not a FlatList — they sit inside the onboarding screen's own ScrollView, and nesting a FlatList (a VirtualizedList) inside a ScrollView with the same orientation breaks touch handling badly enough that list items stopped responding to taps (RN's own "VirtualizedLists should never be nested..." warning undersells how broken this gets in practice).
  • Delete a pet lives on the edit screen (app/(app)/pets/[petId]/edit.tsx, pets.delete + a confirm Alert) — if the deleted pet was the active one, ActivePetContext's new clearActivePetId() resets it so the "auto-pick pets[0]" effect (or OnboardingRedirect, if none remain) takes over.

Dark mode

src/context/ThemeContext.tsx provides a light/dark/auto preference, backed by the same owner.getThemePreference/setThemePreference procedures the web app uses (Gitea #8) — null means "follow system", resolved via React Native's useColorScheme(). The toggle lives at the bottom of the Pets tab (no dedicated settings screen exists yet) as three buttons — Hell/Dunkel/Automatisch — and switches instantly, no reload.

Only screens/components that call useTheme() are actually dark-mode aware; most of the app is still light-only. This isn't an oversight to fix later in five minutes — it's a real constraint of how React Native styles work: StyleSheet.create({...}) at module scope runs once, when the file is first loaded, and bakes in whatever color string it was given at that moment. Changing colors.porchWhite afterwards doesn't touch the frozen style objects already handed to already-mounted components — there's no live binding, unlike a CSS custom property on web. Making a screen theme-aware means converting its module-level const styles = StyleSheet.create({...}) into a function makeStyles(colors: ColorTokens) { return StyleSheet.create({...}) } called via useMemo(() => makeStyles(colors), [colors]) inside the component, with colors coming from useTheme() instead of a static import from theme/tokens.ts. theme/tokens.ts's top comment and ThemeContext.tsx's own comment cover this in more depth.

Converted so far: the shared primitives (Button, TextField, Card, PetAvatar, OptionPicker), PostCard, StoryTray, the Feed tab, the Explore tab, the Notifications tab, the Pets tab, the full pet profile (view/edit/followers/following), the whole health area (consent gate + weight/vet/vaccine sections), and all navigation chrome (root _layout.tsx's status bar, (app)/_layout.tsx and onboarding/_layout.tsx's Stack headers — including headerTintColor, without which the back button's default tint stayed invisible against a dark header — and (tabs)/_layout.tsx's tab bar and headers) — Stack/Tabs options objects are plain props recomputed on every render, so those didn't need the makeStyles dance, just reading colors from useTheme() directly. Not yet converted: Search, Messages, comments, stories, milestones, sign-in/sign-up, and the post/story/milestone creation screens, plus a few shared components whose icon colors don't fully adapt (PawButton, PawBackButton, PetIdentityBadges, RepostCard, VideoPlayer). They'll render correctly in light mode regardless of the device/account theme setting until someone applies the same makeStyles conversion to them.

Internationalization (i18n)

German and English, matching web's supported locale set exactly (src/i18n/request.ts's LOCALES) — mobile intentionally didn't scope this to the wider EN/ES/IT/FR list mentioned in older planning notes, since web itself only ships EN/DE today. i18next + react-i18next (not next-intl, which is Next.js-specific) with one flat "translation" namespace per locale (src/i18n/locales/{en,de}.json), addressed by dotted keys (t("petForm.createButton")) — mirrors next-intl's own per-namespace-object shape closely enough that terminology was copied verbatim from ../messages/{en,de}.json wherever the same concept already existed there (e.g. MilestoneTypes, PetForm).

  • Preference resolution (src/i18n/index.ts) mirrors web's own cookie-only approach: getLanguagePreference()/setLanguagePreference() persist a "de" | "en" | null override to MMKV (null = follow device language, via expo-localization's getLocales()) — there's no backend field for it, same as web's locale living only in the NEXT_LOCALE cookie, never on the Owner row. The switcher lives right below the Darstellung (theme) section on the Pets tab, same three-button pattern (Deutsch/English/Automatisch), and calls queryClient.invalidateQueries() on change so already-cached species/breed names and dates refetch under the new language instead of staying stale.
  • react-i18next's useTranslation() needs no theme-style makeStyles workaround — unlike colors, text updates on every render automatically once i18n.changeLanguage() fires, so switching language re-labels the entire app instantly with no per-screen conversion debt (contrast with "Dark mode" above, which very much has that debt).
  • Backend locale resolution for mobile (src/trpc/init.ts, root CLAUDE.md's createTRPCContext): procedures that resolve DB-stored translatable text (Species/Breed names, via ctx.locale) read a NEXT_LOCALE cookie that a React Native fetch/httpBatchLink request can't set. Mobile's tRPC client (src/api/trpc.ts) sends an x-locale: <current language> header instead; the backend checks the cookie first (web unaffected), then this header (mobile-only fallback). pets.listSpecies/listBreeds take locale as an explicit input rather than reading ctx.locale — those call sites (explore.tsx, PetCreateForm.tsx) pass i18n.language directly instead of a hardcoded "de", which was the actual state before this session (a latent bug: species/breed pickers were German-only while the rest of a pet's data was already resolving to English via ctx.locale's default, since mobile requests never carried a cookie at all).
  • Needs a NAS deploy, unlike most mobile/-only work — the x-locale header fallback is a shared backend file (src/trpc/init.ts), and mobile's live default EXPO_PUBLIC_API_URL points at the deployed site, not local dev (see project_mobile_deployment_target).
  • expo-localization requires a native rebuild (expo prebuild / expo run:android) to actually link — it was added as a new native module via config plugin, the same category of change as the FCM credentials step in "Push notifications" below. Until that rebuild happens on a given device, Localization.getLocales() throws Cannot find native module 'ExpoLocalization'; deviceLocale() in src/i18n/index.ts catches that and falls back to defaultLocale ("en") rather than crashing the app at module-load time — "Automatisch" silently behaves like "English" on an un-rebuilt dev client instead of actually reading the device's system language.

Push notifications

usePushNotifications (in app/_layout.tsx) requests notification permission and registers the resulting Expo push token with the backend (owner.registerPushToken); src/lib/notify.ts on the web side sends a best-effort push through Expo's push API alongside every in-app Notification it creates (follow, reaction, comment, message, ...). Verified end-to-end on 2026-09-06: permission → real Expo push token → registered in the DB → delivered to the Android emulator's notification tray.

Getting here needed three separate one-time setup pieces, each easy to half-do and end up with a silent non-working state:

  1. EAS project id (app.json's extra.eas.projectId) — via eas login + eas init --account pawfeed. Without this, getExpoPushTokenAsync has nothing to attach to and the hook no-ops with [push] No EAS project id configured....
  2. FCM credentials for Android (app.json's googleServicesFilegoogle-services.json, gitignored — redownload from the Firebase Console's org.pawfeed.app Android app on a fresh clone). This is baked into the native project at expo prebuild time, so adding it needs a real rebuild (expo prebuild --platform android + expo run:android), unlike the EAS project id which the dev client picks up from a plain JS reload.
  3. The FCM service account key uploaded to EAS via eas credentials -p android (needs eas.json to exist first, hence that file). The credentials manager has two separate Android push slots — "FCM Legacy" (a plain API key string, and Google shut the underlying Legacy API down in 2024) and "FCM V1" (a Google Service Account JSON key, what Firebase Console's "generate new private key" actually gives you). Uploading the service-account JSON to the wrong slot leaves EAS's send API returning Unable to retrieve the FCM server key for the recipient's app even though a real push token exists and permission is granted — that symptom means step 3 landed in the wrong slot, not that steps 12 are broken.

Also note: getExpoPushTokenAsync can hang indefinitely rather than reject if the device can't reach Google's registration backend — the hook wraps it in a 15s timeout so a real failure surfaces instead of permanent silence.

Deploy note: owner.registerPushToken is a web backend change. Since the mobile app's default EXPO_PUBLIC_API_URL is the live pawfeed.org deployment, this endpoint doesn't exist there until the web app is redeployed too (ssh to the NAS, git pull, ./docker/rolling-deploy.sh — see root CLAUDE.md's deployment section). This is the one case where a "mobile" feature session still needs a web deploy — project_mobile_deployment_target's "mobile changes need no NAS deploy" is about mobile/ code itself, not backend endpoints mobile happens to call.

Content moderation, health-form dates, and image zoom

Three smaller found.md fixes worth documenting since they're easy to regress:

  • Reporting (src/components/ReportSheet.tsx) — a MoreVertical button on PostCard's header opens a bottom sheet with the same 5 ReportReason values as web's ReportSheet.tsx, calling the existing reports.create (no backend change needed). Trimmed versus web: tapping a reason submits immediately, there's no separate optional notes textarea yet even though the backend already accepts one.
  • Health form dates (src/lib/date-format.ts) — the weight/vet-visit/ vaccine forms take DD-MM-YYYY and convert to the backend's YYYY-MM-DD right before the mutation call. The actual found.md "Gesundheitsdaten werden nicht gespeichert" bug wasn't persistence at all — addWeightLog/addVetVisit/addVaccine had no onError handler, so a validation failure (wrong date format) just silently did nothing. All three now show an alert on failure.
  • Pinch-to-zoom (app/(app)/image-viewer.tsx) — a plain PanResponder tracking two-finger distance, driving an Animated.Value scale on the full-size image. Deliberately not react-native-gesture-handler (not a dependency yet) — this is a single scale gesture with no pan/rotate, a raw PanResponder is enough.

Below the language switcher, LegalSection (app/(app)/(tabs)/pets.tsx) links to the same fixed-language legal pages web links to (/impressum, /datenschutz, /nutzungsbedingungen — see root CLAUDE.md, these are never localized) via Linking.openURL, plus the installed app version read from Constants.expoConfig?.version. The language switcher's three buttons use flag emoji (🇩🇪/🇬🇧/🌐) instead of an icon — Android renders these natively, unlike the Windows-Chrome flag-emoji-renders-as-text-codes problem web ran into (fixed there with inline SVGs instead), which doesn't apply on a native Android renderer.