feat(mobile): add Expo/React Native scaffold for PawFeed Android app

Connectivity + auth scaffold only: Expo Router, Clerk session auth via
expo-secure-store, and a type-only tRPC AppRouter import from the web
app's router — no backend changes needed. See mobile/README.md.
This commit is contained in:
admin committed 2026-09-05 11:01:49 +02:00
1 parent c225c9aef7
commit 9eb31c3323
26 files changed
+10401

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+20
View File
@@ -0,0 +1,20 @@
# Copy to .env and fill in. Expo inlines EXPO_PUBLIC_* at build time — never
# put a secret behind this prefix, it ends up in the compiled app bundle.
# Same Clerk instance as the web app — copy the value of
# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY from the repo root's .env.local.
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxx"
# The Next.js backend's public origin (same Docker deployment the web app
# uses — see docker/SCALING-ROADMAP.md). No CORS/proxy changes are needed:
# CORS is a browser-only mechanism and doesn't apply to a native app's fetch
# calls; the existing /api/trpc route and Clerk bearer-token auth already
# work from a native client.
#
# - Physical Android device on the same LAN as the dev machine, hitting the
# `npm run dev` server directly: use the machine's LAN IP, e.g.
# http://192.168.1.50:3000
# - Android emulator talking to a dev server on the host machine: use
# http://10.0.2.2:3000 (the emulator's alias for the host's localhost)
# - Pointing at the real deployment: https://pawfeed.org
EXPO_PUBLIC_API_URL="https://pawfeed.org"
+45
View File
@@ -0,0 +1,45 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# IDE
.idea/
# env files (can opt-in for committing if needed)
.env*
!.env.example
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+125
View File
@@ -0,0 +1,125 @@
# 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:** scaffold only. 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.
### 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
```bash
cd mobile
npm install
cp .env.example .env # already done locally with the live Clerk publishable key
npm run android # or: npx expo start, then press 'a'
```
Requires an Android emulator (Android Studio) or a physical device with
Expo Go / a dev build. 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.
### `.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. |
**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 + tRPC/QueryClient providers + auth redirect
(auth)/
sign-in.tsx # minimal email/password sign-in
(app)/
index.tsx # placeholder home screen — proves the backend connection
src/
api/trpc.ts # tRPC client, AppRouter type import, query client factory
lib/env.ts # typed EXPO_PUBLIC_* access
lib/tokenCache.ts # Clerk session persistence via expo-secure-store
components/ # shared UI components (empty so far)
```
## 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
This is a connectivity + auth scaffold, not a feature-complete app. No pet
feed, no navigation beyond the placeholder home screen, no push
notifications, no camera/media upload flow. Next steps belong in a planning
session once the scaffold itself is confirmed working end-to-end on a real
Android device/emulator.
+30
View File
@@ -0,0 +1,30 @@
{
"expo": {
"name": "PawFeed",
"slug": "pawfeed-mobile",
"scheme": "pawfeed",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.pawfeed.app"
},
"android": {
"package": "org.pawfeed.app",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router", "expo-secure-store"]
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AppLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}
+50
View File
@@ -0,0 +1,50 @@
import { View, Text, Pressable, StyleSheet, ActivityIndicator } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useAuth, useUser } from "@clerk/expo";
import { useTRPC } from "../../src/api/trpc";
/**
* Placeholder home screen — its only job right now is to prove the mobile
* client reaches the same Docker-hosted backend as the web app (public,
* unauthenticated stats.getPublicStats call) and that a signed-in Clerk
* session is available for the protected calls that come next.
*/
export default function HomeScreen() {
const trpc = useTRPC();
const { signOut } = useAuth();
const { user } = useUser();
const { data, isLoading, error } = useQuery(trpc.stats.getPublicStats.queryOptions());
return (
<View style={styles.container}>
<Text style={styles.title}>Angemeldet als {user?.primaryEmailAddress?.emailAddress}</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Backend-Verbindung</Text>
{isLoading && <ActivityIndicator />}
{error && <Text style={styles.error}>{error.message}</Text>}
{data && (
<>
<Text>Owner: {data.ownerCount}</Text>
<Text>Pets: {data.petCount}</Text>
<Text>Sponsoren: {data.sponsorCount}</Text>
</>
)}
</View>
<Pressable style={styles.button} onPress={() => signOut()}>
<Text style={styles.buttonText}>Abmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 16, paddingTop: 64 },
title: { fontSize: 18, fontWeight: "600" },
card: { borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, padding: 16, gap: 4 },
cardTitle: { fontWeight: "600", marginBottom: 8 },
error: { color: "#dc2626" },
button: { backgroundColor: "#111827", borderRadius: 8, padding: 14, alignItems: "center" },
buttonText: { color: "#fff", fontWeight: "600" },
});
+5
View File
@@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}
+74
View File
@@ -0,0 +1,74 @@
import { useState } from "react";
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
import { useSignIn } from "@clerk/expo";
import { useRouter } from "expo-router";
/**
* Minimal email/password sign-in — enough to prove the Clerk <-> backend
* auth flow end to end. Social sign-in / sign-up screens are follow-up work,
* not part of the initial scaffold.
*
* Uses Clerk's newer "Futures" API (signIn.password()/signIn.finalize()) —
* the classic signIn.create()/setActive() pattern from older Clerk docs
* doesn't exist on the installed @clerk/expo version. See AGENTS.md.
*/
export default function SignInScreen() {
const { signIn, fetchStatus } = useSignIn();
const router = useRouter();
const [emailAddress, setEmailAddress] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const onSignInPress = async () => {
setError(null);
const { error: signInError } = await signIn.password({ identifier: emailAddress, password });
if (signInError) {
setError(signInError.longMessage ?? signInError.message);
return;
}
if (signIn.status !== "complete") {
setError("Anmeldung unvollständig — weitere Schritte erforderlich.");
return;
}
await signIn.finalize();
router.replace("/(app)");
};
return (
<View style={styles.container}>
<Text style={styles.title}>PawFeed Login</Text>
<TextInput
autoCapitalize="none"
keyboardType="email-address"
placeholder="E-Mail"
value={emailAddress}
onChangeText={setEmailAddress}
style={styles.input}
/>
<TextInput
placeholder="Passwort"
secureTextEntry
value={password}
onChangeText={setPassword}
style={styles.input}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={styles.button}
disabled={fetchStatus === "fetching"}
onPress={onSignInPress}
>
<Text style={styles.buttonText}>Anmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", padding: 24, gap: 12 },
title: { fontSize: 24, fontWeight: "600", marginBottom: 16 },
input: { borderWidth: 1, borderColor: "#ccc", borderRadius: 8, padding: 12 },
error: { color: "#dc2626" },
button: { backgroundColor: "#111827", borderRadius: 8, padding: 14, alignItems: "center" },
buttonText: { color: "#fff", fontWeight: "600" },
});
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useMemo, useState } from "react";
import { Slot, useRouter, useSegments } from "expo-router";
import { ClerkProvider, ClerkLoaded, useAuth } from "@clerk/expo";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClientProvider } from "@tanstack/react-query";
import { secureTokenCache } from "../src/lib/tokenCache";
import { env } from "../src/lib/env";
import { TRPCProvider, createMobileTRPCClient, makeQueryClient } from "../src/api/trpc";
/**
* Redirects between the (auth) and (app) route groups based on Clerk's
* session state. Mirrors Clerk's official expo-router integration pattern —
* plain useAuth()/useSegments() rather than expo-router's typed route
* guards, since (auth)/(app) group membership isn't otherwise derivable.
*/
function useProtectedRoute() {
const { isLoaded, isSignedIn } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (!isLoaded) return;
const inAuthGroup = segments[0] === "(auth)";
if (!isSignedIn && !inAuthGroup) {
router.replace("/(auth)/sign-in");
} else if (isSignedIn && inAuthGroup) {
router.replace("/(app)");
}
}, [isLoaded, isSignedIn, segments, router]);
}
function ApiProviders({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth();
const [queryClient] = useState(() => makeQueryClient());
const trpcClient = useMemo(() => createMobileTRPCClient(() => getToken()), [getToken]);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
function InitialLayout() {
useProtectedRoute();
return (
<ApiProviders>
<Slot />
</ApiProviders>
);
}
export default function RootLayout() {
return (
<ClerkProvider publishableKey={env.clerkPublishableKey} tokenCache={secureTokenCache}>
<ClerkLoaded>
<SafeAreaProvider>
<InitialLayout />
</SafeAreaProvider>
</ClerkLoaded>
</ClerkProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+20
View File
@@ -0,0 +1,20 @@
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "..");
const config = getDefaultConfig(projectRoot);
// mobile/ is nested inside the OnlyPets repo and shares its root node_modules
// for the type-only AppRouter import (see src/api/trpc.ts) — Metro's default
// watchFolders only cover this project directory, so packages that only
// exist in the root install (e.g. @trpc/server, transitively required by
// @trpc/tanstack-react-query) otherwise fail to resolve.
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
module.exports = config;
+9802
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "pawfeed-mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@clerk/expo": "^4.6.1",
"@tanstack/react-query": "5.101.0",
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.18",
"expo-constants": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}
+37
View File
@@ -0,0 +1,37 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import { QueryClient } from "@tanstack/react-query";
// Type-only import — the mobile app doesn't run any server code, it just
// reuses the web app's router shape for end-to-end type safety. Resolves via
// Node's node_modules walk-up to the web app's own @trpc/server, zod, etc.
// (mobile/ is nested inside the OnlyPets repo, not a separate checkout).
import type { AppRouter } from "../../../src/trpc/routers/_app";
import { env } from "../lib/env";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
export function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
},
});
}
/**
* Builds the tRPC client. `getToken` is Clerk's `useAuth().getToken` — called
* per-request so a refreshed session token is always sent, never cached here.
*/
export function createMobileTRPCClient(getToken: () => Promise<string | null>) {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${env.apiUrl}/api/trpc`,
async headers() {
const token = await getToken();
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
});
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Typed access to the EXPO_PUBLIC_* env vars this app needs.
* Expo inlines EXPO_PUBLIC_* vars at build time — see mobile/.env.example.
*/
function requireEnv(name: string, value: string | undefined): string {
if (!value) {
throw new Error(`Missing required env var ${name} — check mobile/.env (see .env.example)`);
}
return value;
}
export const env = {
apiUrl: requireEnv("EXPO_PUBLIC_API_URL", process.env.EXPO_PUBLIC_API_URL),
clerkPublishableKey: requireEnv(
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
),
};
+32
View File
@@ -0,0 +1,32 @@
import * as SecureStore from "expo-secure-store";
import type { TokenCache } from "@clerk/expo";
/**
* Persists the Clerk session JWT in the OS-level secure keystore (Android
* Keystore / iOS Keychain) instead of Clerk's in-memory default, so the
* signed-in session survives an app restart.
*/
export const secureTokenCache: TokenCache = {
async getToken(key) {
try {
return await SecureStore.getItemAsync(key);
} catch {
return null;
}
},
async saveToken(key, value) {
try {
await SecureStore.setItemAsync(key, value);
} catch {
// Storage write failed (e.g. no keystore available) — session simply
// won't persist across restarts; sign-in flow still works this run.
}
},
async clearToken(key) {
try {
await SecureStore.deleteItemAsync(key);
} catch {
// best-effort cleanup
}
},
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["../src/*"]
}
}
}