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:
1 parent
0323e8d449
commit
a3f19ff70f
19 files changed
+1208
-31
No files matched your search
+36
-11
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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];
|
||||
Reference in new issue
Block a user