Files
petfeed/mobile/README.md
T
adminandClaude Sonnet 5 a3f19ff70f feat(mobile): add health tracking, milestones, messaging, and push registration
Health: consent gate (Art. 9 DSGVO) plus weight/vet-visit/vaccine log
sections, gated behind health.getConsentStatus like the web app.
Milestones: view a pet's milestone posts and create the 5 user-initiated
types with an optional photo. Messaging: chat detail/compose screen
(messages/[conversationId].tsx) polling while focused, reachable from the
conversations list and from a pet profile's new "Nachricht" entry point.
Push: usePushNotifications requests permission and registers the Expo
push token with owner.registerPushToken; real delivery still needs an EAS
project id (`eas init`), tracked in the README.

All four verified live on a real Android emulator: health CRUD (add/list/
delete a weight log), Health/Milestones/Nachricht entry points gated
correctly by own-pet vs. other-pet, and full chat history loading via an
existing conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 09:50:07 +02:00

246 lines
14 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 wired end-to-end but not yet deliverable — see "Push
notifications" 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.
- **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
(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
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
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 → German label/icon map
theme/tokens.ts # design tokens mirroring the web app's DESIGN.md
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar,
# PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer
components/health/ # HealthConsentGate, WeightLogSection, VetVisitSection, VaccineSection
```
## 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`, not yet added) is the recommended path for
producing signed Android/iOS binaries without a local Android
Studio/Xcode toolchain; add it when a first real build is needed.
## 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:
- No i18n — the whole app is hardcoded German text, matching the sign-in screen
### Push notifications — wired but not yet deliverable
`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, ...). Minting
a real token needs an **EAS project id** (`app.json`'s
`extra.eas.projectId`, set once via `eas init` — free, no EAS Build
required, but does need an Expo account login). Until that's done, the
permission prompt shows and is granted, but `getExpoPushTokenAsync` has
nothing to attach to and the hook no-ops with a console warning
(`[push] No EAS project id configured...`) — confirmed via `adb logcat` on
2026-09-06. No Owner row has an `expoPushToken` set until this is run.