diff --git a/mobile/.env.example b/mobile/.env.example index 79ff041..e1a0236 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -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" diff --git a/mobile/README.md b/mobile/README.md index 3587eb9..51bfa71 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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` 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 `` + (`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="" 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 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 diff --git a/mobile/app.json b/mobile/app.json index 23d3c22..d4cce29 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -28,7 +28,10 @@ "plugins": [ "expo-router", "expo-secure-store", - "expo-font" + "expo-font", + "expo-dev-client", + "expo-image", + "expo-system-ui" ] } } diff --git a/mobile/app/(app)/(tabs)/_layout.tsx b/mobile/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..75814e6 --- /dev/null +++ b/mobile/app/(app)/(tabs)/_layout.tsx @@ -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 ( + , + 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 }, + }} + > + }} + /> + }} + /> + + }} + /> + + ); +} + +function MessagesHeaderButton() { + const router = useRouter(); + return ( + router.push("/messages")} hitSlop={12} style={styles.headerButton}> + + + ); +} + +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 ( + + + {count > 0 && ( + + {count > 9 ? "9+" : count} + + )} + + ); +} + +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 }, +}); diff --git a/mobile/app/(app)/(tabs)/explore.tsx b/mobile/app/(app)/(tabs)/explore.tsx new file mode 100644 index 0000000..6b8fcf1 --- /dev/null +++ b/mobile/app/(app)/(tabs)/explore.tsx @@ -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 ( + + + + ); + } + + if (error) { + return ( + + {error.message} + + ); + } + + const pets: ExplorePet[] = data?.pages.flatMap((page) => page.pets) ?? []; + + return ( + pet.id} + numColumns={2} + columnWrapperStyle={styles.row} + contentContainerStyle={styles.list} + renderItem={({ item }) => } + onEndReachedThreshold={0.5} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + ListFooterComponent={isFetchingNextPage ? : null} + ListEmptyComponent={ + + Keine Tiere gefunden. + + } + /> + ); +} + +function ExplorePetCard({ pet }: { pet: ExplorePet }) { + return ( + + + + {formatPetName(pet)} + + {pet._count.followers} Follower + + ); +} + +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 }, +}); diff --git a/mobile/app/(app)/(tabs)/index.tsx b/mobile/app/(app)/(tabs)/index.tsx new file mode 100644 index 0000000..d85d0ec --- /dev/null +++ b/mobile/app/(app)/(tabs)/index.tsx @@ -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 ( + + + + ); + } + + if (error) { + return ( + + {error.message} + + ); + } + + const posts: FeedPost[] = data?.pages.flatMap((page) => page.posts) ?? []; + + return ( + post.id} + contentContainerStyle={styles.list} + renderItem={({ item }) => } + ItemSeparatorComponent={() => } + refreshControl={} + onEndReachedThreshold={0.5} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + ListFooterComponent={isFetchingNextPage ? : null} + ListEmptyComponent={ + + Noch keine Beiträge im Feed. + + } + /> + ); +} + +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 }, +}); diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx new file mode 100644 index 0000000..26d7039 --- /dev/null +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -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 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> = { + 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 ( + + + + ); + } + + if (error) { + return ( + + {error.message} + + ); + } + + const notifications: NotificationItem[] = data?.pages.flatMap((page) => page.notifications) ?? []; + + return ( + item.id} + contentContainerStyle={styles.list} + ItemSeparatorComponent={() => } + onEndReachedThreshold={0.5} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + ListFooterComponent={isFetchingNextPage ? : null} + renderItem={({ item }) => ( + !item.read && markRead.mutate({ notificationId: item.id })} + > + {item.actorPet ? ( + + ) : ( + + {item.type === "BIRTHDAY" ? "🎂" : "🏠"} + + )} + + {getNotificationText(item)} + {formatRelativeTime(item.createdAt)} + + + )} + ListEmptyComponent={ + + Keine Benachrichtigungen. + + } + /> + ); +} + +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 }, +}); diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx new file mode 100644 index 0000000..89e05b1 --- /dev/null +++ b/mobile/app/(app)/(tabs)/pets.tsx @@ -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 ( + + + + ); + } + + return ( + pet.id} + contentContainerStyle={styles.list} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + setActivePetId(item.id)} /> + )} + ListEmptyComponent={ + + Noch keine Tiere angelegt. + + } + /> + ); +} + +function PetRow({ pet, isActive, onPress }: { pet: Pet; isActive: boolean; onPress: () => void }) { + return ( + + + + + + {formatPetName(pet)} + + + {pet.breed?.name ?? pet.species.name} + + {isActive && } + + + ); +} + +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 }, +}); diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 02d4bb0..be4b37c 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -1,5 +1,19 @@ import { Stack } from "expo-router"; +import { colors, fontFamily } from "../../src/theme/tokens"; export default function AppLayout() { - return ; + return ( + + + + + ); } diff --git a/mobile/app/(app)/index.tsx b/mobile/app/(app)/index.tsx deleted file mode 100644 index 6d4d691..0000000 --- a/mobile/app/(app)/index.tsx +++ /dev/null @@ -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 ( - - Angemeldet als {user?.primaryEmailAddress?.emailAddress} - - - Backend-Verbindung - {isLoading && } - {error && {error.message}} - {data && ( - <> - Owner: {data.ownerCount} - Pets: {data.petCount} - Sponsoren: {data.sponsorCount} - - )} - - -