README still said "no EAS Build has been run yet" — the app has since gone through several production builds and is now live on Google Play (closed testing). Also documents the new eas update / EAS Update workflow for shipping JS-only fixes without a new store review, closing out Gitea #35's last remaining task item.
512 lines
30 KiB
Markdown
512 lines
30 KiB
Markdown
# 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.
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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 `googleServicesFile` →
|
||
`google-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 1–2 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.
|
||
|
||
### Pets tab: legal links, version, language flags
|
||
|
||
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.
|