feat(mobile): add health tracking, milestones, messaging, and push registration

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-06 09:50:07 +02:00
1 parent 0323e8d449
commit a3f19ff70f
19 files changed
+1208 -31

No files matched your search

+36 -11
View File
@@ -14,12 +14,13 @@ architecture-decision thread in this session's handoff.
**Status:** Phase 1 — real Feed/Explore/Notifications/Pets tabs against the
live backend, not just a connectivity scaffold anymore. Post creation,
comments, repost, pet profile detail (view/follow/edit/followers-following),
stories (tray/creation/viewer), video playback, and search are done and
verified end-to-end against the live backend. Health tracking, milestones,
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).
stories (tray/creation/viewer), video playback, search, health tracking
(consent gate + weight/vet/vaccine logs), milestones, and message
sending/receiving are done and verified end-to-end against the live backend.
Push notifications are wired end-to-end but not yet deliverable — see "Push
notifications" below. Android is the priority platform; iOS builds need a
Mac + Apple Developer account and come later (same codebase, no separate
source tree — see "Android-first, then iOS" below).
## Architecture
@@ -163,10 +164,14 @@ mobile/
notifications.tsx # notifications list
pets.tsx # owner's pets + active-pet switcher
messages/index.tsx # conversations list
messages/[conversationId].tsx # chat detail/compose, polls messages.list while focused (no realtime)
search.tsx # pets/people/hashtags search, three tabs over one debounced query
post-new.tsx # create a photo post (pick/upload → posts.create)
story-new.tsx # create a photo story (pick/upload → stories.create)
story-viewer.tsx # full-screen story playback (tap zones, progress bars, view tracking)
milestone-new.tsx # create a milestone post (type picker + optional photo → milestones.create)
pets/[petId]/health/index.tsx # weight/vet/vaccine logs, gated by HealthConsentGate
pets/[petId]/milestones.tsx # a pet's milestone posts (milestones.listByPet)
posts/[postId]/comments.tsx # read/write/delete comments for a post
image-viewer.tsx # full-screen tapped-image lightbox
src/
@@ -174,6 +179,7 @@ mobile/
api/types.ts # inferRouterOutputs<AppRouter> shortcuts (Pet, FeedPost, ...)
context/ActivePetContext.tsx # MMKV-backed active-pet state
hooks/useDebounce.ts # generic debounce hook (search input)
hooks/usePushNotifications.ts # permission + Expo push token registration (owner.registerPushToken)
lib/env.ts # typed EXPO_PUBLIC_* access
lib/tokenCache.ts # Clerk session persistence via expo-secure-store
lib/storage.ts # MMKV instance + TanStack Query sync persister
@@ -181,9 +187,11 @@ mobile/
lib/format.ts # pet display name + relative-time formatting
lib/sponsor.ts # active-sponsor check
lib/upload.ts # direct PUT of a picked image to a presigned Supabase URL
lib/milestone-meta.ts # MilestoneType → German label/icon map
theme/tokens.ts # design tokens mirroring the web app's DESIGN.md
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar,
# PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer
components/health/ # HealthConsentGate, WeightLogSection, VetVisitSection, VaccineSection
```
## Android-first, then iOS
@@ -210,11 +218,28 @@ switching, persisted cache for fast reopen) plus a full pet profile
repost ("Paw-Back") creation, stories (tray/creation/viewer via
`expo-video`-adjacent `expo-image`, plus `PawRing` seen/unseen indicator),
video playback (`expo-video` against Mux HLS, polling while `PROCESSING`),
and search (pets/people/hashtags, `search.pets`/`byOwner`/`byHashtag`) are
done and verified end-to-end against the live backend on a real Android
search (pets/people/hashtags, `search.pets`/`byOwner`/`byHashtag`), health
tracking (consent gate + weight/vet visit/vaccine logs — feeding info,
emergency vet, and the shareable Health Card are web-only for now),
milestones (view + create, restricted to the 5 user-initiated types), and
messaging (conversation list + chat detail/compose, polling while the
screen is focused since messages.ts has no realtime mechanism) are done
and verified end-to-end against the live backend on a real Android
emulator. Still missing, roughly in the order they'd unblock the most:
- 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
### Push notifications — wired but not yet deliverable
`usePushNotifications` (in `app/_layout.tsx`) requests notification
permission and registers the resulting Expo push token with the backend
(`owner.registerPushToken`); `src/lib/notify.ts` on the web side sends a
best-effort push through Expo's push API alongside every in-app
Notification it creates (follow, reaction, comment, message, ...). Minting
a real token needs an **EAS project id** (`app.json`'s
`extra.eas.projectId`, set once via `eas init` — free, no EAS Build
required, but does need an Expo account login). Until that's done, the
permission prompt shows and is granted, but `getExpoPushTokenAsync` has
nothing to attach to and the hook no-ops with a console warning
(`[push] No EAS project id configured...`) — confirmed via `adb logcat` on
2026-09-06. No Owner row has an `expoPushToken` set until this is run.
+8 -1
View File
@@ -39,7 +39,14 @@
"cameraPermission": "Erlaube PawFeed den Zugriff auf die Kamera, um ein Foto für deinen Beitrag aufzunehmen."
}
],
"expo-video"
"expo-video",
[
"expo-notifications",
{
"icon": "./assets/android-icon-monochrome.png",
"color": "#f97316"
}
]
]
}
}
+12
View File
@@ -27,7 +27,10 @@ export default function AppLayout() {
<Stack.Screen name="pets/[petId]/edit" options={{ ...headerOptions, title: "Profil bearbeiten" }} />
<Stack.Screen name="pets/[petId]/followers" options={{ ...headerOptions, title: "Follower" }} />
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: "Folgt" }} />
<Stack.Screen name="pets/[petId]/health/index" options={{ ...headerOptions, title: "Gesundheit" }} />
<Stack.Screen name="pets/[petId]/milestones" options={{ ...headerOptions, title: "Meilensteine" }} />
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: "Kommentare" }} />
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: "Nachricht" }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: "Suche" }} />
<Stack.Screen
name="post-new"
@@ -47,6 +50,15 @@ export default function AppLayout() {
headerLeft: () => <CloseModalButton />,
}}
/>
<Stack.Screen
name="milestone-new"
options={{
...headerOptions,
title: "Neuer Meilenstein",
presentation: "modal",
headerLeft: () => <CloseModalButton />,
}}
/>
<Stack.Screen
name="image-viewer"
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade" }}
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { View, Text, FlatList, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { TextField } from "../../../src/components/TextField";
import { Button } from "../../../src/components/Button";
import { formatRelativeTime } from "../../../src/lib/format";
import { colors, radii, typography } from "../../../src/theme/tokens";
import type { ChatMessage } from "../../../src/api/types";
const POLL_INTERVAL_MS = 4000;
/**
* Chat detail/compose screen — messages.ts is pure request/response (no
* realtime mechanism, per README), so new messages arrive via polling while
* this screen is focused rather than a subscription.
*/
export default function ConversationScreen() {
const { conversationId } = useLocalSearchParams<{ conversationId: string }>();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const listRef = useRef<FlatList<ChatMessage>>(null);
const [body, setBody] = useState("");
const { data, isLoading } = useQuery({
...trpc.messages.list.queryOptions({ conversationId, petId: activePetId ?? "" }),
enabled: !!activePetId,
refetchInterval: POLL_INTERVAL_MS,
});
const messages = data?.messages ?? [];
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: trpc.messages.list.queryOptions({ conversationId, petId: activePetId ?? "" }).queryKey,
});
const sendMutation = useMutation(
trpc.messages.send.mutationOptions({
onSuccess: async () => {
setBody("");
await invalidate();
await queryClient.invalidateQueries(trpc.conversations.pathFilter());
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
},
})
);
// Mark received messages read as soon as the list loads (messages.list already
// does this server-side on read, but the inbox badge also needs a fresh count).
useEffect(() => {
if (activePetId) queryClient.invalidateQueries(trpc.conversations.pathFilter());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length]);
const handleSubmit = useCallback(() => {
if (!body.trim() || !activePetId) return;
sendMutation.mutate({ conversationId, senderPetId: activePetId, body: body.trim() });
}, [body, activePetId, conversationId, sendMutation]);
return (
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={80}>
{!isLoading && (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
renderItem={({ item }) => <MessageBubble message={item} isOwn={item.senderPet.id === activePetId} />}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Nachrichten. Schreib die erste!</Text>
</View>
}
/>
)}
<View style={styles.inputRow}>
<TextField
placeholder="Nachricht schreiben…"
value={body}
onChangeText={setBody}
maxLength={1000}
style={styles.input}
/>
<Button
title={sendMutation.isPending ? "…" : "Senden"}
onPress={handleSubmit}
disabled={!body.trim() || sendMutation.isPending}
style={styles.sendButton}
/>
</View>
</KeyboardAvoidingView>
);
}
function MessageBubble({ message, isOwn }: { message: ChatMessage; isOwn: boolean }) {
return (
<View style={[styles.bubbleRow, isOwn && styles.bubbleRowOwn]}>
<View style={[styles.bubble, isOwn ? styles.bubbleOwn : styles.bubbleOther]}>
<Text style={[styles.bubbleText, isOwn && styles.bubbleTextOwn]}>{message.body}</Text>
</View>
<Text style={styles.timestamp}>{formatRelativeTime(message.createdAt)}</Text>
</View>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.porchWhite },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
list: { padding: 16, flexGrow: 1, gap: 10 },
bubbleRow: { alignItems: "flex-start", gap: 3, maxWidth: "80%" },
bubbleRowOwn: { alignSelf: "flex-end", alignItems: "flex-end" },
bubble: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: radii.xl },
bubbleOther: { backgroundColor: colors.whisperGray, borderBottomLeftRadius: 4 },
bubbleOwn: { backgroundColor: colors.marigoldOrange, borderBottomRightRadius: 4 },
bubbleText: { ...typography.body },
bubbleTextOwn: { color: colors.pureWhite },
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10 },
inputRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
padding: 12,
borderTopWidth: 1,
borderTopColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
},
input: { flex: 1 },
sendButton: { width: 88 },
});
+10 -10
View File
@@ -1,5 +1,6 @@
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
@@ -7,13 +8,10 @@ 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).
*/
/** Conversations list (conversations.list) — taps a row to open the chat detail/compose screen. */
export default function MessagesScreen() {
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
const { data, isLoading, error } = useQuery({
@@ -43,7 +41,9 @@ export default function MessagesScreen() {
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => <ConversationRow conversation={item} />}
renderItem={({ item }) => (
<ConversationRow conversation={item} onPress={() => router.push(`/messages/${item.id}`)} />
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Unterhaltungen.</Text>
@@ -53,9 +53,9 @@ export default function MessagesScreen() {
);
}
function ConversationRow({ conversation }: { conversation: Conversation }) {
function ConversationRow({ conversation, onPress }: { conversation: Conversation; onPress: () => void }) {
return (
<View style={styles.row}>
<Pressable style={styles.row} onPress={onPress}>
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} />
<View style={styles.rowText}>
<View style={styles.nameRow}>
@@ -73,7 +73,7 @@ function ConversationRow({ conversation }: { conversation: Conversation }) {
) : null}
</View>
<Text style={styles.timestamp}>{formatRelativeTime(conversation.updatedAt)}</Text>
</View>
</Pressable>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { useCallback, useState } from "react";
import { View, Text, Image as RNImage, Pressable, ScrollView, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { uploadImageToSupabase } from "../../src/lib/upload";
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
import { TextField } from "../../src/components/TextField";
import { Button } from "../../src/components/Button";
import { colors, radii, typography } from "../../src/theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
/**
* Port of web's MilestoneForm — a milestone is a structured Post
* (type=MILESTONE), created for the active pet only. Restricted to the 5
* user-initiated types; follower-threshold milestones are auto-triggered
* server-side (follows.ts) and never shown in this picker.
*/
export default function NewMilestoneScreen() {
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const [milestoneType, setMilestoneType] = useState<CreatableMilestoneType>("BIRTHDAY");
const [notes, setNotes] = useState("");
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
const [error, setError] = useState<string | null>(null);
const getPresignedUrlMutation = useMutation(trpc.posts.getPresignedUrl.mutationOptions());
const createMutation = useMutation(
trpc.milestones.create.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.milestones.pathFilter());
await queryClient.invalidateQueries(trpc.feed.pathFilter());
router.back();
},
onError: (err) => setError(err.message),
})
);
const pickImage = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
if (!result.canceled) setAsset(result.assets[0]);
}, []);
const handleSubmit = useCallback(async () => {
if (!activePetId) return;
setError(null);
try {
let imageKey: string | undefined;
if (asset) {
const mimeType = ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "") ? (asset.mimeType as string) : "image/jpeg";
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
fileSize: asset.fileSize ?? 0,
});
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
imageKey = key;
}
createMutation.mutate({
petId: activePetId,
milestoneType,
notes: notes.trim() || undefined,
imageKey,
});
} catch {
setError("Upload fehlgeschlagen — bitte erneut versuchen.");
}
}, [activePetId, asset, milestoneType, notes, getPresignedUrlMutation, createMutation]);
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.label}>Art des Meilensteins</Text>
<View style={styles.chipRow}>
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
const meta = MILESTONE_META[type];
const Icon = meta.icon;
const active = milestoneType === type;
return (
<Pressable
key={type}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setMilestoneType(type)}
>
<Icon color={active ? colors.pureWhite : colors.marigoldOrange} size={16} />
<Text style={[styles.chipText, active && styles.chipTextActive]}>{meta.label}</Text>
</Pressable>
);
})}
</View>
{asset ? (
<Pressable onPress={() => setAsset(null)}>
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
<Text style={styles.previewHint}>Antippen, um das Foto zu entfernen</Text>
</Pressable>
) : (
<Pressable style={styles.pickerButton} onPress={pickImage}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Foto hinzufügen (optional)</Text>
</Pressable>
)}
<TextField
placeholder="Notizen (optional)"
value={notes}
onChangeText={setNotes}
maxLength={500}
multiline
style={styles.notes}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={isSubmitting ? "Wird geteilt…" : "Meilenstein teilen"} onPress={handleSubmit} disabled={isSubmitting} />
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 14, backgroundColor: colors.porchWhite, flexGrow: 1 },
label: { ...typography.label, textTransform: "none", letterSpacing: 0 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
height: 36,
borderRadius: radii.lg,
backgroundColor: colors.whisperGray,
},
chipActive: { backgroundColor: colors.marigoldOrange },
chipText: { ...typography.body, fontSize: 13 },
chipTextActive: { color: colors.pureWhite },
pickerButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.hairlineGray,
},
pickerText: { ...typography.body, color: colors.ink },
preview: { width: "100%", height: 220, borderRadius: radii.md, backgroundColor: colors.hairlineGray },
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", marginTop: 6 },
notes: { height: 88, textAlignVertical: "top", paddingTop: 12 },
error: { ...typography.body, color: colors.alertRed },
});
@@ -0,0 +1,31 @@
import { ScrollView, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { HealthConsentGate } from "../../../../../src/components/health/HealthConsentGate";
import { WeightLogSection } from "../../../../../src/components/health/WeightLogSection";
import { VetVisitSection } from "../../../../../src/components/health/VetVisitSection";
import { VaccineSection } from "../../../../../src/components/health/VaccineSection";
import { colors } from "../../../../../src/theme/tokens";
/**
* Port of web's src/app/(app)/pets/[petId]/health/page.tsx, scoped to the
* three data-entry sections (weight/vet/vaccines). Feeding info, emergency
* vet, and the shareable Health Card (health.getOrCreateCard) are web-only
* for now — no mobile UI yet, matching the README's health-tracking scope.
*/
export default function PetHealthScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
return (
<ScrollView contentContainerStyle={styles.container}>
<HealthConsentGate petId={petId}>
<WeightLogSection petId={petId} />
<VetVisitSection petId={petId} />
<VaccineSection petId={petId} />
</HealthConsentGate>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 24, backgroundColor: colors.porchWhite, flexGrow: 1 },
});
+38 -7
View File
@@ -1,6 +1,7 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } from "@tanstack/react-query";
import { HeartPulse, Trophy, MessageCircle } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../../src/components/PetAvatar";
@@ -12,12 +13,11 @@ import { formatPetName } from "../../../../src/lib/format";
import { colors, typography } from "../../../../src/theme/tokens";
/**
* Port of web's src/app/(app)/pets/[petId]/page.tsx, scoped to Phase 1.5:
* identity, bio/adoption story, follower/following counts, follow toggle,
* edit entry point (own pet), and the pet's own posts below. No
* ProfileTabs/milestones split, no message button yet (no chat detail
* screen exists to send to) — those match web's ProfileActions.tsx but
* are deferred here.
* Port of web's src/app/(app)/pets/[petId]/page.tsx: identity, bio/adoption
* story, follower/following counts, follow toggle, edit entry point (own
* pet), Health/Milestones/Message entry points, and the pet's own posts
* below. No ProfileTabs split (Milestones is a separate route here, not an
* in-page tab) — that's a web-only layout choice, not a scope gap.
*/
export default function PetProfileScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
@@ -32,6 +32,12 @@ export default function PetProfileScreen() {
const isOwnPet = activePetId === petId;
const getOrCreateConversation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
onSuccess: (conversation) => router.push(`/messages/${conversation.id}`),
})
);
if (isLoading) {
return (
<View style={styles.center}>
@@ -87,6 +93,28 @@ export default function PetProfileScreen() {
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
<View style={styles.iconRow}>
{isOwnPet && (
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
<HeartPulse color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>Gesundheit</Text>
</Pressable>
)}
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
<Trophy color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>Meilensteine</Text>
</Pressable>
{!isOwnPet && activePetId && (
<Pressable
style={styles.iconButton}
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
>
<MessageCircle color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>Nachricht</Text>
</Pressable>
)}
</View>
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
{pet.adoptionStory ? (
<View>
@@ -119,6 +147,9 @@ const styles = StyleSheet.create({
stat: { alignItems: "center" },
statCount: { ...typography.title, fontSize: 18 },
statLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
iconRow: { flexDirection: "row", gap: 20, marginTop: 14 },
iconButton: { alignItems: "center", gap: 4 },
iconButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
section: { ...typography.body, alignSelf: "stretch", marginTop: 12 },
sectionTitle: { ...typography.title, fontSize: 15, alignSelf: "stretch", marginTop: 16 },
divider: { height: 1, backgroundColor: colors.hairlineGray, alignSelf: "stretch", marginTop: 16 },
@@ -0,0 +1,99 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { Plus } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { getMediaUrl } from "../../../../src/lib/media-url";
import { MILESTONE_META, type MilestoneType } from "../../../../src/lib/milestone-meta";
import { colors, radii, typography } from "../../../../src/theme/tokens";
import type { MilestonePost } from "../../../../src/api/types";
/**
* Port of web's Milestones profile tab (milestones.listByPet). Structured
* milestone posts, not a separate data model — see src/trpc/routers/milestones.ts.
*/
export default function MilestonesScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const { activePetId } = useActivePet();
const trpc = useTRPC();
const isOwnPet = activePetId === petId;
const { data: milestones = [], isLoading } = useQuery(trpc.milestones.listByPet.queryOptions({ petId }));
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
return (
<FlatList
data={milestones}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => <MilestoneRow post={item} />}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Meilensteine.</Text>
</View>
}
ListFooterComponent={
isOwnPet ? (
<Pressable style={styles.addButton} onPress={() => router.push("/milestone-new")}>
<Plus color={colors.marigoldOrange} size={18} />
<Text style={styles.addButtonText}>Meilenstein hinzufügen</Text>
</Pressable>
) : null
}
/>
);
}
function MilestoneRow({ post }: { post: MilestonePost }) {
if (!post.milestone) return null;
const meta = MILESTONE_META[post.milestone.milestoneType as MilestoneType];
const Icon = meta.icon;
const image = post.images[0];
return (
<View style={styles.row}>
<View style={styles.iconCircle}>
<Icon color={colors.marigoldOrange} size={20} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle}>{meta.label}</Text>
<Text style={styles.rowDate}>{new Date(post.createdAt).toLocaleDateString("de-DE")}</Text>
{post.milestone.notes ? <Text style={styles.rowNotes}>{post.milestone.notes}</Text> : null}
</View>
{image && <Image source={{ uri: getMediaUrl(image.storageKey) }} style={styles.thumb} contentFit="cover" />}
</View>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
separator: { height: 14 },
row: { flexDirection: "row", alignItems: "center", gap: 12 },
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.marigoldTint,
alignItems: "center",
justifyContent: "center",
},
rowText: { flex: 1, gap: 2 },
rowTitle: { ...typography.body, fontFamily: typography.title.fontFamily },
rowDate: { ...typography.label, textTransform: "none", letterSpacing: 0 },
rowNotes: { ...typography.body, fontSize: 13 },
thumb: { width: 48, height: 48, borderRadius: radii.md },
addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 },
addButtonText: { ...typography.body, color: colors.marigoldOrange, fontFamily: typography.title.fontFamily },
});
+11 -1
View File
@@ -16,6 +16,7 @@ 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 { usePushNotifications } from "../src/hooks/usePushNotifications";
import { colors } from "../src/theme/tokens";
/**
@@ -49,12 +50,21 @@ function ApiProviders({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: queryPersister }}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<ActivePetProvider>{children}</ActivePetProvider>
<ActivePetProvider>
<PushNotificationRegistrar />
{children}
</ActivePetProvider>
</TRPCProvider>
</PersistQueryClientProvider>
);
}
/** No UI — just runs the push-token registration side effect once signed in. */
function PushNotificationRegistrar() {
usePushNotifications();
return null;
}
function LoadingScreen() {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }}>
+34
View File
@@ -23,6 +23,7 @@
"expo-image": "~57.0.4",
"expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-notifications": "~57.0.17",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
@@ -4636,6 +4637,12 @@
}
}
},
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -5671,6 +5678,15 @@
}
}
},
"node_modules/expo-application": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz",
"integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": {
"version": "57.0.16",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.16.tgz",
@@ -5900,6 +5916,24 @@
"react-native": "*"
}
},
"node_modules/expo-notifications": {
"version": "57.0.17",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.17.tgz",
"integrity": "sha512-P/9ZqBOlFBVCZK3T5bhqORtWJhtVBcXQXe/MY1YWuKaxWgKvoGeEvD9ItWuo87jg2nD+eloVmFYqwdhhVCxPlA==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.11.5",
"abort-controller": "^3.0.0",
"badgin": "^1.1.5",
"expo-application": "~57.0.2",
"expo-constants": "~57.0.17"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-router": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.1.11.tgz",
+1
View File
@@ -18,6 +18,7 @@
"expo-image": "~57.0.4",
"expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-notifications": "~57.0.17",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
+3 -1
View File
@@ -1,7 +1,7 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "../../../src/trpc/routers/_app";
type RouterOutputs = inferRouterOutputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export type Pet = RouterOutputs["pets"]["list"][number];
export type FeedPost = RouterOutputs["feed"]["getFeed"]["posts"][number];
@@ -9,3 +9,5 @@ export type ExplorePet = RouterOutputs["explore"]["listPets"]["pets"][number];
export type NotificationItem = RouterOutputs["notifications"]["list"]["notifications"][number];
export type Conversation = RouterOutputs["conversations"]["list"][number];
export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][number];
export type ChatMessage = RouterOutputs["messages"]["list"]["messages"][number];
export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number];
@@ -0,0 +1,172 @@
import { useState, type ReactNode } from "react";
import { View, Text, Pressable, ActivityIndicator, Linking, Alert, StyleSheet } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { HeartPulse, ShieldOff, Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { env } from "../../lib/env";
import { Button } from "../Button";
import { colors, radii, typography } from "../../theme/tokens";
type Props = {
petId: string;
children: ReactNode;
};
/**
* Port of web's src/components/health/HealthConsentGate.tsx. Health-Tracking
* data (weight, vet visits, vaccines) is gated behind explicit, un-pre-checked
* consent per Art. 9 Abs. 2 lit. a DSGVO — this is the UI, health.ts's
* assertHealthConsent is the actual enforcement boundary. Text copied
* verbatim from messages/de.json's Health.consent namespace (mobile has no
* i18n system yet).
*/
export function HealthConsentGate({ petId, children }: Props) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const [checked, setChecked] = useState(false);
const { data: status, isLoading } = useQuery(trpc.health.getConsentStatus.queryOptions({ petId }));
const invalidateHealthQueries = () => {
queryClient.invalidateQueries({ queryKey: trpc.health.getConsentStatus.queryOptions({ petId }).queryKey });
queryClient.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryOptions({ petId }).queryKey });
queryClient.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryOptions({ petId }).queryKey });
queryClient.invalidateQueries({ queryKey: trpc.health.listVaccines.queryOptions({ petId }).queryKey });
};
const giveMutation = useMutation(
trpc.health.giveConsent.mutationOptions({
onSuccess: invalidateHealthQueries,
onError: () => Alert.alert("Einwilligung konnte nicht gespeichert werden."),
})
);
const revokeMutation = useMutation(
trpc.health.revokeConsent.mutationOptions({
onSuccess: invalidateHealthQueries,
onError: () => Alert.alert("Einwilligung konnte nicht widerrufen werden."),
})
);
const confirmRevoke = () => {
Alert.alert(
"Einwilligung wirklich widerrufen?",
"Dadurch werden alle Gewichtseinträge, Tierarztbesuche und Impfungen dieses Haustiers unwiderruflich gelöscht.",
[
{ text: "Abbrechen", style: "cancel" },
{
text: "Endgültig widerrufen und löschen",
style: "destructive",
onPress: () => revokeMutation.mutate({ petId }),
},
]
);
};
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
if (!status?.hasConsented) {
return (
<View style={styles.consentContainer}>
<View style={styles.titleRow}>
<HeartPulse color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>Einwilligung für Gesundheitsdaten</Text>
</View>
<Text style={styles.subtitle}>
Bevor Sie den Health-Tracking-Bereich nutzen können, benötigen wir Ihre ausdrückliche Einwilligung zur
Verarbeitung der Gesundheitsdaten Ihres Haustiers.
</Text>
<Pressable style={styles.checkboxRow} onPress={() => setChecked((v) => !v)}>
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
{checked && <Check color={colors.pureWhite} size={14} />}
</View>
<Text style={styles.checkboxLabel}>
Ich willige ausdrücklich ein, dass Gesundheitsdaten meines Haustiers (Gewicht, Tierarztbesuche,
Impfungen) gespeichert und verarbeitet werden. Ich kann diese Einwilligung jederzeit widerrufen; danach
werden alle Gesundheitsdaten dieses Haustiers automatisch gelöscht.
</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.privacyLink}>Weitere Informationen in unserer Datenschutzerklärung</Text>
</Pressable>
<View style={styles.ctaRow}>
<Button title="Abbrechen" variant="outline" style={styles.ctaButton} onPress={() => router.back()} />
<Button
title={giveMutation.isPending ? "…" : "Zustimmen und fortfahren"}
style={styles.ctaButton}
disabled={!checked || giveMutation.isPending}
onPress={() => giveMutation.mutate({ petId })}
/>
</View>
</View>
);
}
return (
<>
{children}
<View style={styles.manageBox}>
<ShieldOff color={colors.alertRed} size={18} />
<View style={styles.manageText}>
<Text style={styles.manageTitle}>Einwilligung verwalten</Text>
<Text style={styles.manageDesc}>
Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.
</Text>
</View>
<Pressable onPress={confirmRevoke} disabled={revokeMutation.isPending}>
<Text style={styles.revokeLink}>Widerrufen</Text>
</Pressable>
</View>
</>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
consentContainer: { padding: 20, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
checkboxRow: { flexDirection: "row", gap: 12, padding: 14, borderRadius: radii.xl, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 16 },
checkbox: {
width: 20,
height: 20,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
alignItems: "center",
justifyContent: "center",
marginTop: 2,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
privacyLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 20 },
ctaRow: { flexDirection: "row", gap: 10 },
ctaButton: { flex: 1 },
manageBox: {
flexDirection: "row",
alignItems: "flex-start",
gap: 10,
margin: 16,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.alertRedTint,
backgroundColor: colors.alertRedTint,
},
manageText: { flex: 1 },
manageTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
manageDesc: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 2 },
revokeLink: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily, fontSize: 13 },
});
@@ -0,0 +1,113 @@
import { useState } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { TextField } from "../TextField";
import { Button } from "../Button";
import { Card } from "../Card";
import { colors, typography } from "../../theme/tokens";
const today = () => new Date().toISOString().slice(0, 10);
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
export function VaccineSection({ petId }: { petId: string }) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [dateGiven, setDateGiven] = useState(today());
const [nextDueDate, setNextDueDate] = useState("");
const [notes, setNotes] = useState("");
const { data: vaccines = [], isLoading } = useQuery(trpc.health.listVaccines.queryOptions({ petId }));
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: trpc.health.listVaccines.queryOptions({ petId }).queryKey });
const addMutation = useMutation(
trpc.health.addVaccine.mutationOptions({
onSuccess: () => {
invalidate();
setShowForm(false);
setName("");
setNextDueDate("");
setNotes("");
setDateGiven(today());
},
})
);
const deleteMutation = useMutation(trpc.health.deleteVaccine.mutationOptions({ onSuccess: invalidate }));
const canSubmit = name.trim().length > 0 && dateGiven.length > 0 && !addMutation.isPending;
const isOverdue = (nextDue: string | null) => !!nextDue && new Date(nextDue).getTime() < Date.now();
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Impfungen</Text>
<Pressable onPress={() => setShowForm((v) => !v)} hitSlop={8}>
<Plus color={colors.marigoldOrange} size={20} />
</Pressable>
</View>
{showForm && (
<Card style={styles.form}>
<TextField placeholder="Impfstoff, z.B. Tollwut, DHPP" value={name} onChangeText={setName} maxLength={100} />
<TextField placeholder="Gegeben am (JJJJ-MM-TT)" value={dateGiven} onChangeText={setDateGiven} />
<TextField placeholder="Nächste Fälligkeit (optional, JJJJ-MM-TT)" value={nextDueDate} onChangeText={setNextDueDate} />
<TextField placeholder="Notizen (optional)" value={notes} onChangeText={setNotes} maxLength={200} />
<Button
title={addMutation.isPending ? "…" : "Speichern"}
disabled={!canSubmit}
onPress={() =>
addMutation.mutate({
petId,
name: name.trim(),
dateGiven,
nextDueDate: nextDueDate.trim() || undefined,
notes: notes.trim() || undefined,
})
}
/>
</Card>
)}
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : vaccines.length === 0 ? (
<Text style={styles.empty}>Noch keine Impfungen eingetragen</Text>
) : (
vaccines.map((vaccine) => (
<View key={vaccine.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{vaccine.name}</Text>
<Text style={styles.rowSecondary}>Gegeben {new Date(vaccine.dateGiven).toLocaleDateString("de-DE")}</Text>
{vaccine.nextDueDate && (
<Text style={[styles.rowSecondary, isOverdue(vaccine.nextDueDate) && styles.overdue]}>
{isOverdue(vaccine.nextDueDate) ? "Überfällig" : `Fällig ${new Date(vaccine.nextDueDate).toLocaleDateString("de-DE")}`}
</Text>
)}
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: vaccine.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
))
)}
</View>
);
}
const styles = StyleSheet.create({
section: { gap: 10 },
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
sectionTitle: { ...typography.title, fontSize: 16 },
form: { gap: 10 },
empty: { ...typography.body, color: colors.quietGray },
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
rowText: { flex: 1, gap: 2 },
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
overdue: { color: colors.alertRed },
});
@@ -0,0 +1,111 @@
import { useState } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { TextField } from "../TextField";
import { Button } from "../Button";
import { Card } from "../Card";
import { colors, typography } from "../../theme/tokens";
const today = () => new Date().toISOString().slice(0, 10);
/** Port of web's health.ts vetVisit CRUD — see health/page.tsx's Tierarzt tab. */
export function VetVisitSection({ petId }: { petId: string }) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [date, setDate] = useState(today());
const [reason, setReason] = useState("");
const [vetName, setVetName] = useState("");
const [notes, setNotes] = useState("");
const { data: visits = [], isLoading } = useQuery(trpc.health.listVetVisits.queryOptions({ petId }));
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryOptions({ petId }).queryKey });
const addMutation = useMutation(
trpc.health.addVetVisit.mutationOptions({
onSuccess: () => {
invalidate();
setShowForm(false);
setReason("");
setVetName("");
setNotes("");
setDate(today());
},
})
);
const deleteMutation = useMutation(trpc.health.deleteVetVisit.mutationOptions({ onSuccess: invalidate }));
const canSubmit = date.length > 0 && reason.trim().length > 0 && !addMutation.isPending;
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Tierarzt</Text>
<Pressable onPress={() => setShowForm((v) => !v)} hitSlop={8}>
<Plus color={colors.marigoldOrange} size={20} />
</Pressable>
</View>
{showForm && (
<Card style={styles.form}>
<TextField placeholder="JJJJ-MM-TT" value={date} onChangeText={setDate} />
<TextField placeholder="Grund, z.B. Jahresuntersuchung" value={reason} onChangeText={setReason} maxLength={100} />
<TextField placeholder="Tierarzt / Klinik (optional)" value={vetName} onChangeText={setVetName} maxLength={100} />
<TextField placeholder="Notizen (optional)" value={notes} onChangeText={setNotes} maxLength={500} multiline />
<Button
title={addMutation.isPending ? "…" : "Speichern"}
disabled={!canSubmit}
onPress={() =>
addMutation.mutate({
petId,
date,
reason: reason.trim(),
vetName: vetName.trim() || undefined,
notes: notes.trim() || undefined,
})
}
/>
</Card>
)}
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : visits.length === 0 ? (
<Text style={styles.empty}>Noch keine Tierarztbesuche</Text>
) : (
visits.map((visit) => (
<View key={visit.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{visit.reason}</Text>
<Text style={styles.rowSecondary}>
{new Date(visit.date).toLocaleDateString("de-DE")}
{visit.vetName ? ` · ${visit.vetName}` : ""}
</Text>
{visit.notes ? <Text style={styles.rowNotes}>{visit.notes}</Text> : null}
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: visit.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
))
)}
</View>
);
}
const styles = StyleSheet.create({
section: { gap: 10 },
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
sectionTitle: { ...typography.title, fontSize: 16 },
form: { gap: 10 },
empty: { ...typography.body, color: colors.quietGray },
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
rowText: { flex: 1, gap: 2 },
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
rowNotes: { ...typography.body, fontSize: 13, marginTop: 2 },
});
@@ -0,0 +1,99 @@
import { useState } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { TextField } from "../TextField";
import { Button } from "../Button";
import { Card } from "../Card";
import { colors, typography } from "../../theme/tokens";
const today = () => new Date().toISOString().slice(0, 10);
/** Port of web's health.ts weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */
export function WeightLogSection({ petId }: { petId: string }) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [date, setDate] = useState(today());
const [weightKg, setWeightKg] = useState("");
const [notes, setNotes] = useState("");
const { data: logs = [], isLoading } = useQuery(trpc.health.listWeightLogs.queryOptions({ petId }));
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryOptions({ petId }).queryKey });
const addMutation = useMutation(
trpc.health.addWeightLog.mutationOptions({
onSuccess: () => {
invalidate();
setShowForm(false);
setWeightKg("");
setNotes("");
setDate(today());
},
})
);
const deleteMutation = useMutation(trpc.health.deleteWeightLog.mutationOptions({ onSuccess: invalidate }));
const parsedWeight = Number(weightKg.replace(",", "."));
const canSubmit = date.length > 0 && parsedWeight > 0 && !addMutation.isPending;
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Gewicht</Text>
<Pressable onPress={() => setShowForm((v) => !v)} hitSlop={8}>
<Plus color={colors.marigoldOrange} size={20} />
</Pressable>
</View>
{showForm && (
<Card style={styles.form}>
<TextField placeholder="JJJJ-MM-TT" value={date} onChangeText={setDate} />
<TextField placeholder="Gewicht (kg), z.B. 4,2" value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
<TextField placeholder="Notizen (optional)" value={notes} onChangeText={setNotes} maxLength={200} />
<Button
title={addMutation.isPending ? "…" : "Speichern"}
disabled={!canSubmit}
onPress={() => addMutation.mutate({ petId, date, weightKg: parsedWeight, notes: notes.trim() || undefined })}
/>
</Card>
)}
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : logs.length === 0 ? (
<Text style={styles.empty}>Noch keine Gewichtseinträge</Text>
) : (
logs.map((log) => (
<View key={log.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString("de-DE")} kg</Text>
<Text style={styles.rowSecondary}>
{new Date(log.date).toLocaleDateString("de-DE")}
{log.notes ? ` · ${log.notes}` : ""}
</Text>
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
))
)}
</View>
);
}
const styles = StyleSheet.create({
section: { gap: 10 },
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
sectionTitle: { ...typography.title, fontSize: 16 },
form: { gap: 10 },
empty: { ...typography.body, color: colors.quietGray },
row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
rowText: { flex: 1, gap: 2 },
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
});
+73
View File
@@ -0,0 +1,73 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { useMutation } from "@tanstack/react-query";
import { useAuth } from "@clerk/expo";
import { useTRPC } from "../api/trpc";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
/**
* Requests notification permission, mints this device's Expo push token, and
* registers it with the backend (owner.registerPushToken) so server-side
* events (messages, follows, reactions, ...) can push to this device — see
* src/lib/notify.ts on the web side, which sends through Expo's push API.
*
* Minting a real token needs an EAS project id (app.json's
* extra.eas.projectId, set once via `eas init` — a free step, no EAS Build
* required). Until that's set up, this no-ops with a console warning rather
* than crashing: push delivery is best-effort everywhere else in this stack
* (a missing/expired token just means notify.ts silently skips the push).
*/
export function usePushNotifications(): void {
const trpc = useTRPC();
const { isSignedIn } = useAuth();
const registerMutation = useMutation(trpc.owner.registerPushToken.mutationOptions());
useEffect(() => {
if (!isSignedIn || Platform.OS === "web") return;
let cancelled = false;
async function register() {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const permission = await Notifications.requestPermissionsAsync();
if (permission.status !== "granted") return;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
console.warn(
"[push] No EAS project id configured (app.json extra.eas.projectId) — run `eas init` once to enable real push tokens."
);
return;
}
try {
const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
if (!cancelled) registerMutation.mutate({ expoPushToken: token });
} catch (err) {
console.warn("[push] Failed to get Expo push token", err);
}
}
register();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
}
+55
View File
@@ -0,0 +1,55 @@
import { Cake, Heart, Leaf, Stethoscope, Milk, Trophy, Star, Award, type LucideIcon } from "lucide-react-native";
export type MilestoneType =
| "BIRTHDAY"
| "ADOPTION_DAY"
| "FIRST_OUTING"
| "VET_VISIT"
| "LITTER"
| "FOLLOWERS_500"
| "FOLLOWERS_1K"
| "FOLLOWERS_2_5K"
| "FOLLOWERS_5K"
| "FOLLOWERS_10K"
| "FOLLOWERS_25K"
| "FOLLOWERS_50K"
| "FOLLOWERS_100K"
| "FOLLOWERS_250K"
| "FOLLOWERS_500K"
| "FOLLOWERS_1M";
interface MilestoneMeta {
label: string;
icon: LucideIcon;
}
/** Port of web's src/lib/milestone-meta.ts, German labels (mobile has no i18n system yet). */
export const MILESTONE_META: Record<MilestoneType, MilestoneMeta> = {
BIRTHDAY: { label: "Geburtstag", icon: Cake },
ADOPTION_DAY: { label: "Adoptionstag", icon: Heart },
FIRST_OUTING: { label: "Erster Ausflug", icon: Leaf },
VET_VISIT: { label: "Tierarztbesuch", icon: Stethoscope },
LITTER: { label: "Neuer Wurf", icon: Milk },
FOLLOWERS_500: { label: "500 Follower", icon: Trophy },
FOLLOWERS_1K: { label: "1.000 Follower", icon: Trophy },
FOLLOWERS_2_5K: { label: "2.500 Follower", icon: Trophy },
FOLLOWERS_5K: { label: "5.000 Follower", icon: Trophy },
FOLLOWERS_10K: { label: "10.000 Follower", icon: Star },
FOLLOWERS_25K: { label: "25.000 Follower", icon: Star },
FOLLOWERS_50K: { label: "50.000 Follower", icon: Star },
FOLLOWERS_100K: { label: "100.000 Follower", icon: Star },
FOLLOWERS_250K: { label: "250.000 Follower", icon: Award },
FOLLOWERS_500K: { label: "500.000 Follower", icon: Award },
FOLLOWERS_1M: { label: "1.000.000 Follower", icon: Award },
};
/** The 5 user-initiated types shown in the creation picker — follower-threshold types are auto-triggered server-side. */
export const USER_INITIATED_MILESTONE_TYPES = [
"BIRTHDAY",
"ADOPTION_DAY",
"FIRST_OUTING",
"VET_VISIT",
"LITTER",
] as const;
export type CreatableMilestoneType = (typeof USER_INITIATED_MILESTONE_TYPES)[number];