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:
1 parent
c225c9aef7
commit
9eb31c3323
26 files changed
+10401
No files matched your search
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AppLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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 |
@@ -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;
|
||||
Generated
+9802
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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}` } : {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["../src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user