feat(mobile): Phase 1 — real Feed/Explore/Notifications/Pets tabs

Moves the mobile app from a connectivity scaffold to a real Phase 1 app:

- Migrate off Expo Go onto a local custom dev client (expo-dev-client,
  built via expo prebuild + expo run:android against the local Android
  SDK — no EAS account needed). Needed because react-native-mmkv requires
  native code Expo Go doesn't ship.
- Persisted, MMKV-backed TanStack Query cache (PersistQueryClientProvider)
  plus expo-image, so a relaunch renders the last-known feed/pets/
  notifications instantly instead of a blank screen while refetching.
- ActivePetContext: every pet-scoped procedure requires an explicit petId
  server-side (no server-side "current pet"), ported from web's
  ActivePetContext, MMKV-backed instead of localStorage.
- Bottom Tabs (Feed/Explore/Notifications/Pets) mirroring the web app's
  actual mobile nav, with Messages as a header button rather than a tab
  (matches web). Feed, PostCard, PawButton reactions, pet discovery grid,
  notifications list, and a conversations list are all wired to the real
  backend and verified against the live database.
- Design tokens/components extended with PostCard/PawButton/PetAvatar/
  PetIdentityBadges, all styled from the existing src/theme/tokens.ts.
- Fixed a react/react-dom version mismatch surfaced along the way, and
  handle Clerk's needs_client_trust (Device Trust) sign-in status with an
  email-code step.

Verified end-to-end in the Android emulator against pawfeed.org: feed
scrolls and paginates with real posts/images, paw reactions toggle
optimistically and persist, pet switching updates every screen, and
notifications/explore show live data.
This commit is contained in:
admin committed 2026-09-05 13:19:01 +02:00
1 parent c4aaa8b3d6
commit c35d4cda46
26 files changed
+1476 -96

No files matched your search

+5
View File
@@ -18,3 +18,8 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxx"
# 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"
# Same Supabase project the web app uses for public media URLs — copy the
# value of NEXT_PUBLIC_SUPABASE_URL from the repo root's .env.local. Safe to
# embed client-side (it's the public storage origin, not a service key).
EXPO_PUBLIC_SUPABASE_URL="https://xxxxxxxxxxxx.supabase.co"
+105 -18
View File
@@ -11,9 +11,13 @@ 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).
**Status:** Phase 1 — real Feed/Explore/Notifications/Pets tabs against the
live backend, not just a connectivity scaffold anymore. Post creation,
comments, stories, pet profile detail, video playback, search, health, and
message sending are still not built — see "What's not built yet" 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
@@ -36,7 +40,33 @@ source tree — see "Android-first, then iOS" below).
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.
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
@@ -57,17 +87,47 @@ as-is, and both already were:
## 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
npm run android # or: npx expo start, then press 'a'
# 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
```
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.
`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`
@@ -75,6 +135,7 @@ per `AGENTS.md` in this folder.
|---|---|
| `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]]).
@@ -88,16 +149,30 @@ emulator, or the dev machine's LAN IP from a physical device).
```
mobile/
app/ # expo-router routes
_layout.tsx # ClerkProvider + tRPC/QueryClient providers + auth redirect
_layout.tsx # ClerkProvider + persisted QueryClient/tRPC + ActivePetProvider + auth redirect
(auth)/
sign-in.tsx # minimal email/password sign-in
sign-in.tsx # email/password + Clerk Device Trust email-code step
(app)/
index.tsx # placeholder home screen — proves the backend connection
_layout.tsx # Stack hosting the (tabs) group + the messages screen
(tabs)/
_layout.tsx # bottom Tabs: Feed/Explore/Notifications/Pets + header Messages button
index.tsx # Feed
explore.tsx # pet discovery grid
notifications.tsx # notifications list
pets.tsx # owner's pets + active-pet switcher
messages/index.tsx # conversations list
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
lib/env.ts # typed EXPO_PUBLIC_* access
lib/tokenCache.ts # Clerk session persistence via expo-secure-store
components/ # shared UI components (empty so far)
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
theme/tokens.ts # design tokens mirroring the web app's DESIGN.md
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar, PetIdentityBadges
```
## Android-first, then iOS
@@ -118,8 +193,20 @@ separately. "Starting with Android" means:
## 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.
Phase 1 (Feed/Explore/Notifications/Pets tabs, reactions, active-pet
switching, persisted cache for fast reopen) is 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:
- Post creation/upload (camera/gallery → Supabase direct upload, per
`posts.getPresignedUrl`/`posts.create`)
- Comments (reading + posting — `PostDetailDialog` equivalent)
- Pet profile detail screen (`pets.getProfile`)
- Repost creation (the count already renders, creating one doesn't)
- Stories (story ring, viewer, creation)
- Video playback (currently a static placeholder for `type === "VIDEO"` posts)
- Search (`search.pets`/`byHashtag`/`byOwner`) — separate from the Explore tab
- Health tracking, milestones
- Sending messages (conversations list works; no chat detail/compose screen)
- Push notifications
- No i18n — the whole app is hardcoded German text, matching the sign-in screen
+4 -1
View File
@@ -28,7 +28,10 @@
"plugins": [
"expo-router",
"expo-secure-store",
"expo-font"
"expo-font",
"expo-dev-client",
"expo-image",
"expo-system-ui"
]
}
}
+95
View File
@@ -0,0 +1,95 @@
import { View, Text, Pressable, StyleSheet } from "react-native";
import { Tabs, useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Home, Compass, Bell, Heart, MessageCircle } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { colors, fontFamily } from "../../../src/theme/tokens";
/**
* Bottom tab bar mirroring the product's real mobile IA (web's
* src/components/layout/MobileNav.tsx): Feed, Explore, Notifications, Pets.
* Messages is a header button, not a tab, matching web (there it's a fixed
* floating corner button). The center "+" post button from web has no
* equivalent here yet — post creation is a later phase.
*/
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: true,
headerRight: () => <MessagesHeaderButton />,
headerStyle: { backgroundColor: colors.pureWhite },
headerTitleStyle: { fontFamily: fontFamily.bold },
tabBarActiveTintColor: colors.marigoldOrange,
tabBarInactiveTintColor: colors.quietGray,
tabBarStyle: { backgroundColor: colors.pureWhite, borderTopColor: colors.hairlineGray },
tabBarLabelStyle: { fontFamily: fontFamily.semiBold, fontSize: 11 },
}}
>
<Tabs.Screen
name="index"
options={{ title: "Feed", tabBarIcon: ({ color, size }) => <Home color={color} size={size} /> }}
/>
<Tabs.Screen
name="explore"
options={{ title: "Explore", tabBarIcon: ({ color, size }) => <Compass color={color} size={size} /> }}
/>
<Tabs.Screen
name="notifications"
options={{ title: "Benachrichtigungen", tabBarIcon: NotificationsTabIcon }}
/>
<Tabs.Screen
name="pets"
options={{ title: "Meine Tiere", tabBarIcon: ({ color, size }) => <Heart color={color} size={size} /> }}
/>
</Tabs>
);
}
function MessagesHeaderButton() {
const router = useRouter();
return (
<Pressable onPress={() => router.push("/messages")} hitSlop={12} style={styles.headerButton}>
<MessageCircle color={colors.ink} size={22} />
</Pressable>
);
}
function NotificationsTabIcon({ color, size }: { color: string; size: number }) {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { data } = useQuery({
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
enabled: !!activePetId,
});
const count = data?.count ?? 0;
return (
<View>
<Bell color={color} size={size} />
{count > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
headerButton: { marginRight: 16 },
badge: {
position: "absolute",
top: -4,
right: -8,
backgroundColor: colors.marigoldOrange,
borderRadius: 8,
minWidth: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 3,
},
badgeText: { color: colors.pureWhite, fontSize: 10, fontFamily: fontFamily.semiBold },
});
+85
View File
@@ -0,0 +1,85 @@
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { Card } from "../../../src/components/Card";
import { formatPetName } from "../../../src/lib/format";
import { colors, typography } from "../../../src/theme/tokens";
import type { ExplorePet } from "../../../src/api/types";
/**
* Basic pet-discovery grid (explore.listPets) — web's algorithmic
* "trending posts + hashtags" view (explore.getTrending) is a later phase.
*/
export default function ExploreScreen() {
const trpc = useTRPC();
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, error } = useInfiniteQuery(
trpc.explore.listPets.infiniteQueryOptions(
{ limit: 30 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor, initialCursor: null }
)
);
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error.message}</Text>
</View>
);
}
const pets: ExplorePet[] = data?.pages.flatMap((page) => page.pets) ?? [];
return (
<FlatList
data={pets}
keyExtractor={(pet) => pet.id}
numColumns={2}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.list}
renderItem={({ item }) => <ExplorePetCard pet={item} />}
onEndReachedThreshold={0.5}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={styles.footer} color={colors.marigoldOrange} /> : null}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Keine Tiere gefunden.</Text>
</View>
}
/>
);
}
function ExplorePetCard({ pet }: { pet: ExplorePet }) {
return (
<Card style={styles.card}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
<Text style={styles.name} numberOfLines={1}>
{formatPetName(pet)}
</Text>
<Text style={styles.followers}>{pet._count.followers} Follower</Text>
</Card>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
row: { gap: 12 },
card: { flex: 1, alignItems: "center", gap: 6, marginBottom: 12 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
errorText: { ...typography.body, color: colors.alertRed },
name: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 14 },
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
footer: { marginVertical: 16 },
});
+72
View File
@@ -0,0 +1,72 @@
import { View, Text, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from "react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PostCard } from "../../../src/components/PostCard";
import { colors, typography } from "../../../src/theme/tokens";
import type { FeedPost } from "../../../src/api/types";
/**
* Port of web's src/components/feed/FeedList.tsx, scoped to Phase 1: real
* infinite-scroll feed data, no ads/story tray/feed-mode toggle yet.
*/
export default function FeedScreen() {
const trpc = useTRPC();
const { activePetId, isLoading: isActivePetLoading } = useActivePet();
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch, isRefetching, error } =
useInfiniteQuery({
...trpc.feed.getFeed.infiniteQueryOptions(
{ petId: activePetId ?? "", limit: 20, mode: "chrono" },
{ getNextPageParam: (lastPage) => lastPage.nextCursor, initialCursor: null }
),
enabled: !!activePetId,
});
if (isActivePetLoading || (isLoading && !data)) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error.message}</Text>
</View>
);
}
const posts: FeedPost[] = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<FlatList
data={posts}
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => <PostCard post={item} actingPetId={activePetId!} />}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.marigoldOrange} />}
onEndReachedThreshold={0.5}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={styles.footer} color={colors.marigoldOrange} /> : null}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Beiträge im Feed.</Text>
</View>
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
separator: { height: 24 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
errorText: { ...typography.body, color: colors.alertRed },
footer: { marginVertical: 16 },
});
+128
View File
@@ -0,0 +1,128 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
import { colors, typography } from "../../../src/theme/tokens";
import type { NotificationItem } from "../../../src/api/types";
const ACTOR_MESSAGES: Partial<Record<NotificationItem["type"], (actor: string) => string>> = {
FOLLOW: (actor) => `${actor} folgt dir jetzt.`,
REACTION: (actor) => `${actor} hat deinen Beitrag mit einer Pfote versehen.`,
COMMENT: (actor) => `${actor} hat deinen Beitrag kommentiert.`,
MESSAGE: (actor) => `${actor} hat dir eine Nachricht geschickt.`,
MENTION: (actor) => `${actor} hat dich erwähnt.`,
PAW_BACK: (actor) => `${actor} hat zurückgepfotet.`,
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
// backend never sets actorPetId for them (see src/app/api/cron/anniversaries
// on web), so there's no actor to name. Wording ported from messages/de.json.
const SELF_MESSAGES: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "🎂 Alles Gute zum Geburtstag!",
ADOPTION_DAY: "🏠 Heute ist der Adoptionstag!",
};
function getNotificationText(item: NotificationItem): string {
if (SELF_MESSAGES[item.type]) return SELF_MESSAGES[item.type]!;
if (item.actorPet) return ACTOR_MESSAGES[item.type]?.(formatPetName(item.actorPet)) ?? item.type;
return item.type;
}
/** Port of web's notifications list — mark-read on tap, no deep-linking to the target post/comment yet. */
export default function NotificationsScreen() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, error } = useInfiniteQuery({
...trpc.notifications.list.infiniteQueryOptions(
{ petId: activePetId ?? "" },
{ getNextPageParam: (lastPage) => lastPage.nextCursor, initialCursor: null }
),
enabled: !!activePetId,
});
const markRead = useMutation(
trpc.notifications.markRead.mutationOptions({
onSuccess: () => queryClient.invalidateQueries(trpc.notifications.pathFilter()),
})
);
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error.message}</Text>
</View>
);
}
const notifications: NotificationItem[] = data?.pages.flatMap((page) => page.notifications) ?? [];
return (
<FlatList
data={notifications}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
onEndReachedThreshold={0.5}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={styles.footer} color={colors.marigoldOrange} /> : null}
renderItem={({ item }) => (
<Pressable
style={[styles.row, !item.read && styles.rowUnread]}
onPress={() => !item.read && markRead.mutate({ notificationId: item.id })}
>
{item.actorPet ? (
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
) : (
<View style={styles.selfIcon}>
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
</View>
)}
<View style={styles.rowText}>
<Text style={typography.body}>{getNotificationText(item)}</Text>
<Text style={styles.timestamp}>{formatRelativeTime(item.createdAt)}</Text>
</View>
</Pressable>
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Keine Benachrichtigungen.</Text>
</View>
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
separator: { height: 8 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
errorText: { ...typography.body, color: colors.alertRed },
row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 },
rowUnread: { backgroundColor: colors.marigoldTint },
rowText: { flex: 1, gap: 2 },
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
footer: { marginVertical: 16 },
selfIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.marigoldTint,
alignItems: "center",
justifyContent: "center",
},
selfIconText: { fontSize: 18 },
});
+74
View File
@@ -0,0 +1,74 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { Check } from "lucide-react-native";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { PetIdentityBadges } from "../../../src/components/PetIdentityBadges";
import { Card } from "../../../src/components/Card";
import { formatPetName } from "../../../src/lib/format";
import { colors, typography } from "../../../src/theme/tokens";
import type { Pet } from "../../../src/api/types";
/**
* List of the owner's own pets (pets.list()). Tapping a pet sets it as the
* "active pet" used for every pet-scoped action elsewhere in the app —
* mobile's stand-in for web's separate pet-switcher dropdown, since there's
* no header chrome to put one in yet. Tapping through to a pet's public
* profile is a later phase (getProfile screen not built yet).
*/
export default function PetsScreen() {
const { pets, activePetId, setActivePetId, isLoading } = useActivePet();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
return (
<FlatList
data={pets}
keyExtractor={(pet) => pet.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => (
<PetRow pet={item} isActive={item.id === activePetId} onPress={() => setActivePetId(item.id)} />
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Tiere angelegt.</Text>
</View>
}
/>
);
}
function PetRow({ pet, isActive, onPress }: { pet: Pet; isActive: boolean; onPress: () => void }) {
return (
<Pressable onPress={onPress}>
<Card style={styles.row}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
<View style={styles.rowText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} />
</View>
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
</View>
{isActive && <Check color={colors.marigoldOrange} size={20} />}
</Card>
</Pressable>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
separator: { height: 12 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
row: { flexDirection: "row", alignItems: "center", gap: 12 },
rowText: { flex: 1, gap: 2 },
nameRow: { flexDirection: "row", alignItems: "center" },
name: { ...typography.title, fontSize: 16 },
breed: { ...typography.body, color: colors.quietGray },
});
+15 -1
View File
@@ -1,5 +1,19 @@
import { Stack } from "expo-router";
import { colors, fontFamily } from "../../src/theme/tokens";
export default function AppLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="messages/index"
options={{
headerShown: true,
title: "Nachrichten",
headerStyle: { backgroundColor: colors.pureWhite },
headerTitleStyle: { fontFamily: fontFamily.bold },
}}
/>
</Stack>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useAuth, useUser } from "@clerk/expo";
import { useTRPC } from "../../src/api/trpc";
import { Button } from "../../src/components/Button";
import { Card } from "../../src/components/Card";
import { colors, typography } from "../../src/theme/tokens";
/**
* 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>
<Card>
<Text style={styles.cardTitle}>Backend-Verbindung</Text>
{isLoading && <ActivityIndicator color={colors.marigoldOrange} />}
{error && <Text style={styles.error}>{error.message}</Text>}
{data && (
<>
<Text style={styles.cardBody}>Owner: {data.ownerCount}</Text>
<Text style={styles.cardBody}>Pets: {data.petCount}</Text>
<Text style={styles.cardBody}>Sponsoren: {data.sponsorCount}</Text>
</>
)}
</Card>
<Button title="Abmelden" variant="outline" onPress={() => signOut()} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
gap: 16,
paddingTop: 64,
backgroundColor: colors.porchWhite,
},
title: { ...typography.title },
cardTitle: { ...typography.label, marginBottom: 4 },
cardBody: { ...typography.body },
error: { ...typography.body, color: colors.alertRed },
});
+101
View File
@@ -0,0 +1,101 @@
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
import { colors, typography } from "../../../src/theme/tokens";
import type { Conversation } from "../../../src/api/types";
/**
* Conversations list only (conversations.list) — no chat detail/send screen
* yet, matches web's messages.ts having zero realtime mechanism (pure
* request/response, so this would need to poll once a detail screen exists).
*/
export default function MessagesScreen() {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { data, isLoading, error } = useQuery({
...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }),
enabled: !!activePetId,
});
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error.message}</Text>
</View>
);
}
return (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => <ConversationRow conversation={item} />}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Unterhaltungen.</Text>
</View>
}
/>
);
}
function ConversationRow({ conversation }: { conversation: Conversation }) {
return (
<View style={styles.row}>
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} />
<View style={styles.rowText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(conversation.otherPet)}</Text>
{conversation.unreadCount > 0 && (
<View style={styles.unreadDot}>
<Text style={styles.unreadCount}>{conversation.unreadCount}</Text>
</View>
)}
</View>
{conversation.lastMessage ? (
<Text style={styles.preview} numberOfLines={1}>
{conversation.lastMessage.body}
</Text>
) : null}
</View>
<Text style={styles.timestamp}>{formatRelativeTime(conversation.updatedAt)}</Text>
</View>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
separator: { height: 12 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
errorText: { ...typography.body, color: colors.alertRed },
row: { flexDirection: "row", alignItems: "center", gap: 12 },
rowText: { flex: 1, gap: 2 },
nameRow: { flexDirection: "row", alignItems: "center", gap: 6 },
name: { ...typography.body, fontFamily: typography.title.fontFamily },
preview: { ...typography.body, color: colors.quietGray },
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
unreadDot: {
backgroundColor: colors.marigoldOrange,
borderRadius: 8,
minWidth: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 3,
},
unreadCount: { color: colors.pureWhite, fontSize: 10 },
});
+6 -4
View File
@@ -3,7 +3,7 @@ import { ActivityIndicator, View } from "react-native";
import { Slot, useRouter, useSegments } from "expo-router";
import { ClerkProvider, useAuth } from "@clerk/expo";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClientProvider } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
useFonts,
Inter_400Regular,
@@ -14,6 +14,8 @@ import {
import { secureTokenCache } from "../src/lib/tokenCache";
import { env } from "../src/lib/env";
import { TRPCProvider, createMobileTRPCClient, makeQueryClient } from "../src/api/trpc";
import { queryPersister } from "../src/lib/storage";
import { ActivePetProvider } from "../src/context/ActivePetContext";
import { colors } from "../src/theme/tokens";
/**
@@ -45,11 +47,11 @@ function ApiProviders({ children }: { children: React.ReactNode }) {
const trpcClient = useMemo(() => createMobileTRPCClient(() => getToken()), [getToken]);
return (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: queryPersister }}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
<ActivePetProvider>{children}</ActivePetProvider>
</TRPCProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
);
}
+368 -11
View File
@@ -10,21 +10,30 @@
"dependencies": {
"@clerk/expo": "^4.6.1",
"@expo-google-fonts/inter": "^0.4.2",
"@tanstack/react-query": "5.101.0",
"@tanstack/query-sync-storage-persister": "^5.102.8",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-query-persist-client": "^5.102.8",
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.18",
"expo-constants": "~57.0.16",
"expo-dev-client": "~57.0.18",
"expo-font": "~57.0.3",
"expo-image": "~57.0.4",
"expo-linking": "~57.0.8",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-mmkv": "^4.3.2",
"react-native-nitro-modules": "^0.37.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4"
},
"devDependencies": {
"@types/react": "~19.2.2",
@@ -3964,13 +3973,40 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"node_modules/@tanstack/query-persist-client-core": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.102.8.tgz",
"integrity": "sha512-t3v4/D6ejo/BrPzM5gm/AT3c4CWPCxFa+0cIjAeHAeanGPG6rCjFS1hZnPZH/rf55qe3qYYgx5U6iBZMY1PQjA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
"@tanstack/query-core": "5.102.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-sync-storage-persister": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.102.8.tgz",
"integrity": "sha512-UvIpYl6jipKJHfITAieQaXMvVPoJh2IkQkWDYZNRPycoGbNYdqB+UdRVoXkZagB3EdeKnlC7sA61grsEC87Wfw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.102.8",
"@tanstack/query-persist-client-core": "5.102.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
"integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.102.8"
},
"funding": {
"type": "github",
@@ -3980,14 +4016,21 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query/node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"node_modules/@tanstack/react-query-persist-client": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz",
"integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-persist-client-core": "5.102.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.102.8",
"react": "^18 || ^19"
}
},
"node_modules/@trpc/client": {
@@ -4660,6 +4703,12 @@
"license": "MIT",
"peer": true
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/borsh": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz",
@@ -5202,6 +5251,56 @@
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5327,6 +5426,61 @@
"integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5354,6 +5508,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -5530,6 +5696,65 @@
"react-native": "*"
}
},
"node_modules/expo-dev-client": {
"version": "57.0.18",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.18.tgz",
"integrity": "sha512-cT+6iO4rRoguIxeONAPHF2wbAlc002tZya2M3xHlc6EWKptjsAL2Vr+kagYdLH/4vtLX7XKhWvOzaBf+rQpRFg==",
"license": "MIT",
"dependencies": {
"expo-dev-launcher": "~57.0.19",
"expo-dev-menu": "~57.0.18",
"expo-dev-menu-interface": "~57.0.0",
"expo-manifests": "~57.0.1",
"expo-updates-interface": "~57.0.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-launcher": {
"version": "57.0.19",
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.19.tgz",
"integrity": "sha512-IF/EJ3tcHqiyfBb5VtpAU3upBXRVTFVuVoWqEzrkZCesuMi35td4yQFvPtqXbMceaEErgFyiyxhW+L6AXVYapA==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^57.0.2",
"expo-dev-menu": "~57.0.18",
"expo-manifests": "~57.0.1"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-dev-launcher/node_modules/@expo/schema-utils": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-57.0.2.tgz",
"integrity": "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==",
"license": "MIT"
},
"node_modules/expo-dev-menu": {
"version": "57.0.18",
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.18.tgz",
"integrity": "sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw==",
"license": "MIT",
"dependencies": {
"expo-dev-menu-interface": "~57.0.0"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-dev-menu-interface": {
"version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz",
"integrity": "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-font": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.3.tgz",
@@ -5544,6 +5769,32 @@
"react-native": "*"
}
},
"node_modules/expo-image": {
"version": "57.0.4",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.4.tgz",
"integrity": "sha512-DLVNjqIMLb71wh0F27kRv7nA9i4g80fvkVugiimVgGnO7Iot+gRSv5VhrFJCvX/OlSzbGQqd/3ktlEaPGFqP7g==",
"license": "MIT",
"dependencies": {
"sf-symbols-typescript": "^2.2.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-json-utils": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz",
"integrity": "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==",
"license": "MIT"
},
"node_modules/expo-linking": {
"version": "57.0.8",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.8.tgz",
@@ -5558,6 +5809,18 @@
"react-native": "*"
}
},
"node_modules/expo-manifests": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz",
"integrity": "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==",
"license": "MIT",
"dependencies": {
"expo-json-utils": "~57.0.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "57.0.12",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.12.tgz",
@@ -5690,6 +5953,35 @@
"react-native": "*"
}
},
"node_modules/expo-system-ui": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.3.tgz",
"integrity": "sha512-3N3BoagGojEvQw0h7kL0Lvv2LrVjqEvODSNS+Nc9lj/kzaTaVx4K1HI8qqxeALMf5jui3rLP2b3Ai2pXotoNuA==",
"license": "MIT",
"dependencies": {
"@react-native/normalize-colors": "0.86.3",
"debug": "^4.3.2"
},
"peerDependencies": {
"expo": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-updates-interface": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
"integrity": "sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "57.0.21",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.21.tgz",
@@ -7102,6 +7394,17 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react-native": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.41.0.tgz",
"integrity": "sha512-iQk/8xRzd5BscNwTRGtiUkTfUfbq9+UT0zkgbLnmWco+T0Diw95TVgntM4j53oB+HBL5oenUFxIWm9TmCxKU7A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -7117,6 +7420,12 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -7623,6 +7932,18 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -8367,6 +8688,27 @@
"react-native": "*"
}
},
"node_modules/react-native-mmkv": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-4.3.2.tgz",
"integrity": "sha512-49OAyfkg0/TMWiWELZN6VuVQPZPhizwL4DTmp8b7B1md3dB/s3LH3mGfC3T+lp9W0y/rqxZMEnotLFTIbOAenQ==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-nitro-modules": "*"
}
},
"node_modules/react-native-nitro-modules": {
"version": "0.37.1",
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.37.1.tgz",
"integrity": "sha512-KpW6EQVQ/bfegpCGxN9Be+ndvsfj56t4IESEvCASu9gWpJa/NDfHpIeIwCOvZQ3eXmLEj6YD4wCLqcC1FJPr2w==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-safe-area-context": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
@@ -8391,6 +8733,21 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.15.4",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
"integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-url-polyfill": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-4.0.0.tgz",
+14 -5
View File
@@ -5,30 +5,39 @@
"dependencies": {
"@clerk/expo": "^4.6.1",
"@expo-google-fonts/inter": "^0.4.2",
"@tanstack/react-query": "5.101.0",
"@tanstack/query-sync-storage-persister": "^5.102.8",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-query-persist-client": "^5.102.8",
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.18",
"expo-constants": "~57.0.16",
"expo-dev-client": "~57.0.18",
"expo-font": "~57.0.3",
"expo-image": "~57.0.4",
"expo-linking": "~57.0.8",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-mmkv": "^4.3.2",
"react-native-nitro-modules": "^0.37.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"start": "expo start --dev-client",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"private": true
+7 -1
View File
@@ -13,7 +13,13 @@ export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
export function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
queries: {
staleTime: 30 * 1000,
// Kept in memory (and persisted to MMKV) well past a typical
// background/close cycle, so reopening the app renders the last
// known data instantly instead of a blank/loading screen.
gcTime: 24 * 60 * 60 * 1000,
},
},
});
}
+10
View File
@@ -0,0 +1,10 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "../../../src/trpc/routers/_app";
type RouterOutputs = inferRouterOutputs<AppRouter>;
export type Pet = RouterOutputs["pets"]["list"][number];
export type FeedPost = RouterOutputs["feed"]["getFeed"]["posts"][number];
export type ExplorePet = RouterOutputs["explore"]["listPets"]["pets"][number];
export type NotificationItem = RouterOutputs["notifications"]["list"]["notifications"][number];
export type Conversation = RouterOutputs["conversations"]["list"][number];
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import { Pressable, Text, StyleSheet } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { PawPrint } from "lucide-react-native";
import { useTRPC } from "../api/trpc";
import { colors, fontFamily } from "../theme/tokens";
type Props = {
postId: string;
petId: string;
initialCount: number;
};
/**
* Port of web's src/components/feed/PawButton.tsx. `reacted` always starts
* `false` on mount — the web app's feed response doesn't tell the client
* whether the viewer already reacted (postInclude has no reactions relation),
* an acknowledged simplification on web that this mirrors rather than fixes.
* Same optimistic-update shape: flip locally in onMutate, reconcile the exact
* count from the server's authoritative `reacted` in onSuccess, roll back in
* onError.
*/
export function PawButton({ postId, petId, initialCount }: Props) {
const trpc = useTRPC();
const [reacted, setReacted] = useState(false);
const [count, setCount] = useState(initialCount);
const toggleMutation = useMutation(
trpc.reactions.toggle.mutationOptions({
onMutate: () => {
const prevReacted = reacted;
const prevCount = count;
setReacted(!reacted);
return { prevReacted, prevCount };
},
onSuccess: (data, _vars, context) => {
setReacted(data.reacted);
if (context) {
setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1));
}
},
onError: (_err, _vars, context) => {
if (context) {
setReacted(context.prevReacted);
setCount(context.prevCount);
}
},
})
);
return (
<Pressable
style={styles.button}
onPress={() => toggleMutation.mutate({ postId, petId })}
hitSlop={8}
>
<PawPrint
size={20}
color={reacted ? colors.marigoldOrange : colors.quietGray}
fill={reacted ? colors.marigoldOrange : "transparent"}
/>
<Text style={styles.count}>{count}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: { flexDirection: "row", alignItems: "center", gap: 6 },
count: { fontFamily: fontFamily.regular, fontSize: 13, color: colors.quietGray },
});
+44
View File
@@ -0,0 +1,44 @@
import { View, Text, StyleSheet } from "react-native";
import { Image } from "expo-image";
import { getAvatarUrl } from "../lib/media-url";
import { colors, fontFamily } from "../theme/tokens";
const SIZE_PRESETS = { sm: 32, md: 40, lg: 56 } as const;
type Props = {
name: string;
avatarKey?: string | null;
size?: keyof typeof SIZE_PRESETS;
};
/**
* Port of web's src/components/ui/pet-avatar.tsx — image if the pet has one,
* otherwise a colored initial fallback.
*/
export function PetAvatar({ name, avatarKey, size = "md" }: Props) {
const px = SIZE_PRESETS[size];
const uri = getAvatarUrl(avatarKey);
const dimensionStyle = { width: px, height: px, borderRadius: px / 2 };
if (uri) {
return <Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />;
}
return (
<View style={[styles.fallback, dimensionStyle]}>
<Text style={[styles.fallbackText, { fontSize: px * 0.4 }]}>{name.charAt(0).toUpperCase()}</Text>
</View>
);
}
const styles = StyleSheet.create({
fallback: {
backgroundColor: colors.marigoldTint,
alignItems: "center",
justifyContent: "center",
},
fallbackText: {
color: colors.marigoldDeep,
fontFamily: fontFamily.semiBold,
},
});
@@ -0,0 +1,26 @@
import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles } from "lucide-react-native";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
type Props = {
isVerified?: boolean;
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
};
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
export function PetIdentityBadges({ isVerified, sponsor }: Props) {
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
if (!isVerified && !showSponsor) return null;
return (
<View style={styles.row}>
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: "row", gap: 4, marginLeft: 4 },
});
+117
View File
@@ -0,0 +1,117 @@
import { View, Text, StyleSheet, ScrollView, useWindowDimensions } from "react-native";
import { Image } from "expo-image";
import { MessageCircle, Repeat2, Video } from "lucide-react-native";
import { PetAvatar } from "./PetAvatar";
import { PetIdentityBadges } from "./PetIdentityBadges";
import { PawButton } from "./PawButton";
import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { colors, typography, radii } from "../theme/tokens";
import type { FeedPost } from "../api/types";
type Props = {
post: FeedPost;
/** The signed-in owner's currently active pet — the one reacting/acting. */
actingPetId: string;
};
/**
* Port of web's src/components/feed/PostCard.tsx, scoped to what Phase 1
* needs: pet header, image (carousel for 2+), caption, and the reaction/
* comment/repost row. Comment and repost are static counts in this pass —
* PostDetailDialog and repost creation are later phases, not built yet.
* Video posts and milestone posts get a minimal placeholder rather than
* full treatment (video playback UI is an explicit fast-follow).
*/
export function PostCard({ post, actingPetId }: Props) {
const { width } = useWindowDimensions();
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
return (
<View style={styles.card}>
<View style={styles.header}>
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
</View>
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
</View>
</View>
{post.type === "VIDEO" ? (
<View style={[styles.videoPlaceholder, { width: cardWidth }]}>
<Video size={32} color={colors.pureWhite} />
</View>
) : (
<PostImages images={post.images} width={cardWidth} />
)}
{post.caption ? <Text style={styles.caption}>{post.caption}</Text> : null}
<View style={styles.actions}>
<PawButton postId={post.id} petId={actingPetId} initialCount={post.reactionCount ?? 0} />
<View style={styles.actionGroup}>
<MessageCircle size={18} color={colors.quietGray} />
<Text style={styles.actionCount}>{post.commentCount ?? 0}</Text>
</View>
<View style={styles.actionGroup}>
<Repeat2 size={18} color={colors.quietGray} />
<Text style={styles.actionCount}>{post.repostCount ?? 0}</Text>
</View>
</View>
</View>
);
}
function PostImages({ images, width }: { images: FeedPost["images"]; width: number }) {
if (images.length === 0) return null;
if (images.length === 1) {
return <PostImage image={images[0]} width={width} />;
}
return (
<ScrollView horizontal pagingEnabled showsHorizontalScrollIndicator={false} style={{ width }}>
{images.map((image) => (
<PostImage key={image.id} image={image} width={width} />
))}
</ScrollView>
);
}
function PostImage({ image, width }: { image: FeedPost["images"][number]; width: number }) {
const rawRatio = image.width && image.height ? image.width / image.height : 1;
const aspectRatio = Math.min(1.91, Math.max(0.75, rawRatio));
return (
<Image
source={{ uri: getMediaUrl(image.storageKey) }}
style={{ width, aspectRatio, borderRadius: radii.md }}
contentFit="cover"
placeholder={image.blurhash ? { blurhash: image.blurhash } : undefined}
transition={150}
/>
);
}
const styles = StyleSheet.create({
card: { gap: 10 },
header: { flexDirection: "row", alignItems: "center", gap: 10 },
headerText: { flex: 1 },
nameRow: { flexDirection: "row", alignItems: "center" },
name: { ...typography.body, fontFamily: typography.title.fontFamily },
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
caption: { ...typography.body },
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
actionCount: { ...typography.body, color: colors.quietGray, fontSize: 13 },
videoPlaceholder: {
aspectRatio: 9 / 16,
borderRadius: radii.md,
backgroundColor: colors.ink,
alignItems: "center",
justifyContent: "center",
},
});
+61
View File
@@ -0,0 +1,61 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@clerk/expo";
import { useTRPC } from "../api/trpc";
import { storage } from "../lib/storage";
import type { Pet } from "../api/types";
const ACTIVE_PET_KEY = "active_pet_id";
/**
* Port of the web app's src/context/ActivePetContext.tsx. 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" concept, so the client has to track and supply
* one. MMKV replaces web's localStorage; defaults to the owner's first pet.
*/
type ActivePetContextValue = {
activePetId: string | null;
setActivePetId: (id: string) => void;
pets: Pet[];
isLoading: boolean;
};
const ActivePetContext = createContext<ActivePetContextValue | null>(null);
export function ActivePetProvider({ children }: { children: ReactNode }) {
const trpc = useTRPC();
const { isSignedIn } = useAuth();
const { data: pets, isLoading } = useQuery({
...trpc.pets.list.queryOptions(),
enabled: !!isSignedIn,
});
const [activePetId, setActivePetIdState] = useState<string | null>(
() => storage.getString(ACTIVE_PET_KEY) ?? null
);
useEffect(() => {
if (!activePetId && pets && pets.length > 0) {
storage.set(ACTIVE_PET_KEY, pets[0].id);
setActivePetIdState(pets[0].id);
}
}, [activePetId, pets]);
const setActivePetId = (id: string) => {
storage.set(ACTIVE_PET_KEY, id);
setActivePetIdState(id);
};
const value = useMemo(
() => ({ activePetId, setActivePetId, pets: pets ?? [], isLoading }),
[activePetId, pets, isLoading]
);
return <ActivePetContext.Provider value={value}>{children}</ActivePetContext.Provider>;
}
export function useActivePet(): ActivePetContextValue {
const ctx = useContext(ActivePetContext);
if (!ctx) throw new Error("useActivePet must be used within ActivePetProvider");
return ctx;
}
+1
View File
@@ -16,4 +16,5 @@ export const env = {
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
),
supabaseUrl: requireEnv("EXPO_PUBLIC_SUPABASE_URL", process.env.EXPO_PUBLIC_SUPABASE_URL),
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Ports of web's src/lib/pet-display-name.ts and format-relative-time.ts.
* Hardcoded German strings rather than next-intl (mobile has no i18n system
* yet — the whole app is German-only for now, matching sign-in.tsx).
*/
export function formatPetName(pet: { name: string; nickname?: string | null }): string {
return pet.nickname ? `${pet.name} (${pet.nickname})` : pet.name;
}
export function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "gerade eben";
if (diffMins < 60) return `vor ${diffMins} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays === 1 ? "" : "en"}`;
return new Date(date).toLocaleDateString("de-DE");
}
+14
View File
@@ -0,0 +1,14 @@
import { env } from "./env";
/**
* Port of the web app's src/lib/media-url.ts / avatar-url.ts — both build the
* same public Supabase Storage URL, storage keys in the DB are relative
* (e.g. "posts/{ownerId}/{petId}/{uuid}.jpg"), never full URLs.
*/
export function getMediaUrl(storageKey: string): string {
return `${env.supabaseUrl}/storage/v1/object/public/pet-avatars/${storageKey}`;
}
export function getAvatarUrl(avatarKey: string | null | undefined): string | undefined {
return avatarKey ? getMediaUrl(avatarKey) : undefined;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Port of web's src/lib/sponsor.ts. tRPC responses carry no superjson
* transformer (checked: none configured server-side), so `Date`-typed
* fields actually arrive as ISO strings at runtime despite the inferred
* TS type — always coerce with `new Date(...)` before comparing.
*/
export function isActiveSponsor(owner: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
}): boolean {
if (!owner.sponsorSince) return false;
if (!owner.sponsorPeriodEnd) return true;
return new Date(owner.sponsorPeriodEnd).getTime() > Date.now();
}
+19
View File
@@ -0,0 +1,19 @@
import { createMMKV } from "react-native-mmkv";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
/**
* Single MMKV instance backing both the persisted TanStack Query cache and
* app-local state (active pet, feed mode, ...). Synchronous reads are what
* make instant-on-reopen possible — AsyncStorage can't rehydrate the query
* cache before first paint the way MMKV's sync getString/set can.
*/
export const storage = createMMKV();
export const queryPersister = createSyncStoragePersister({
storage: {
getItem: (key) => storage.getString(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.remove(key),
},
key: "pawfeed-query-cache",
});