From 4a35fce20b206b3fe5ad9e82e177f845e080a71b Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 6 Sep 2026 18:55:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20found.md=20batch=20=E2=80=94=20r?= =?UTF-8?q?eports,=20dark=20mode,=20health=20form=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new report feature: a "..." button on every post opens a reason-picker sheet (reports.create), matching web's ReportSheet reasons/copy - pet profile, edit, followers/following, and the whole health screen (incl. consent gate) converted to the makeStyles(colors) dark-mode pattern - fixed the invisible back button on every pushed (app)/_layout.tsx screen: headerTintColor was never set, so the native-stack default back chevron kept its platform tint instead of the theme color — black-on-black in dark mode looked like a missing button entirely - health forms (weight/vet visit/vaccine) now take dates as DD-MM-YYYY instead of the backend's raw YYYY-MM-DD, converting internally (src/lib/date-format.ts); invalid dates now show an error instead of the mutation silently failing with no onError handler at all — this, not persistence, was the actual "Gesundheitsdaten werden nicht gespeichert" bug - pinch-to-zoom on the full-screen image viewer via a plain PanResponder (no new native dependency) - Pets tab: language switcher now uses flag emoji (🇩🇪/🇬🇧/🌐), and a new footer section links Impressum/Datenschutz/Nutzungsbedingungen plus the installed app version All live-verified against the deployed backend except pinch-zoom (two-finger gestures aren't simulatable over adb). --- mobile/app/(app)/(tabs)/pets.tsx | 44 ++++++-- mobile/app/(app)/_layout.tsx | 5 + mobile/app/(app)/image-viewer.tsx | 65 +++++++++++- mobile/app/(app)/pets/[petId]/edit.tsx | 48 +++++---- mobile/app/(app)/pets/[petId]/followers.tsx | 20 ++-- mobile/app/(app)/pets/[petId]/following.tsx | 20 ++-- .../app/(app)/pets/[petId]/health/index.tsx | 14 ++- mobile/app/(app)/pets/[petId]/index.tsx | 51 +++++---- mobile/found.md | 23 +++- mobile/src/components/PostCard.tsx | 39 ++++--- mobile/src/components/ReportSheet.tsx | 100 ++++++++++++++++++ .../components/health/HealthConsentGate.tsx | 86 ++++++++------- .../src/components/health/VaccineSection.tsx | 77 +++++++++----- .../src/components/health/VetVisitSection.tsx | 71 ++++++++----- .../components/health/WeightLogSection.tsx | 55 ++++++---- mobile/src/i18n/index.ts | 16 +-- mobile/src/i18n/locales/de.json | 27 ++++- mobile/src/i18n/locales/en.json | 27 ++++- mobile/src/lib/date-format.ts | 41 +++++++ 19 files changed, 615 insertions(+), 214 deletions(-) create mode 100644 mobile/src/components/ReportSheet.tsx create mode 100644 mobile/src/lib/date-format.ts diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx index 2d89164..d63d7ed 100644 --- a/mobile/app/(app)/(tabs)/pets.tsx +++ b/mobile/app/(app)/(tabs)/pets.tsx @@ -1,12 +1,14 @@ import { useMemo, useState } from "react"; -import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native"; +import { View, Text, FlatList, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { Check, Sun, Moon, SunMoon, Languages } from "lucide-react-native"; +import Constants from "expo-constants"; +import { Check, Sun, Moon, SunMoon } from "lucide-react-native"; import { useActivePet } from "../../../src/context/ActivePetContext"; import { useTheme } from "../../../src/context/ThemeContext"; import { getLanguagePreference, setLanguagePreference, type Locale } from "../../../src/i18n"; +import { env } from "../../../src/lib/env"; import { PetAvatar } from "../../../src/components/PetAvatar"; import { PetIdentityBadges } from "../../../src/components/PetIdentityBadges"; import { Card } from "../../../src/components/Card"; @@ -63,6 +65,7 @@ export default function PetsScreen() { <> + } /> @@ -164,9 +167,9 @@ function LanguageSection() { const [preference, setPreferenceState] = useState(() => getLanguagePreference()); const languageOptions = [ - { value: "de" as const, label: t("pets.languageGerman") }, - { value: "en" as const, label: t("pets.languageEnglish") }, - { value: null, label: t("pets.languageAuto") }, + { value: "de" as const, label: t("pets.languageGerman"), flag: "🇩🇪" }, + { value: "en" as const, label: t("pets.languageEnglish"), flag: "🇬🇧" }, + { value: null, label: t("pets.languageAuto"), flag: "🌐" }, ]; const handleSelect = (value: Locale | null) => { @@ -187,7 +190,7 @@ function LanguageSection() { style={[styles.appearanceOption, active && styles.appearanceOptionActive]} onPress={() => handleSelect(option.value)} > - + {option.flag} {option.label} @@ -199,6 +202,31 @@ function LanguageSection() { ); } +/** Links to the fixed-language legal pages (see root CLAUDE.md: not localized, always German) plus the installed app version, for anyone who needs to reach them without going through the web app. */ +function LegalSection() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); + const version = Constants.expoConfig?.version ?? "?"; + + const links = [ + { label: t("pets.legalImpressum"), path: "impressum" }, + { label: t("pets.legalPrivacy"), path: "datenschutz" }, + { label: t("pets.legalTerms"), path: "nutzungsbedingungen" }, + ]; + + return ( + + {links.map((link) => ( + Linking.openURL(`${env.apiUrl}/${link.path}`)}> + {link.label} + + ))} + {t("pets.versionLabel", { version })} + + ); +} + function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); return StyleSheet.create({ @@ -236,5 +264,9 @@ function makeStyles(colors: ColorTokens) { appearanceOptionActive: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange }, appearanceOptionText: { ...typography.body, fontSize: 13, color: colors.ink }, appearanceOptionTextActive: { color: colors.pureWhite }, + flagEmoji: { fontSize: 16 }, + legalSection: { marginTop: 28, gap: 12, alignItems: "center" }, + legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep }, + versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 }, }); } diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 0581c76..38945c0 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -26,6 +26,11 @@ export default function AppLayout() { headerShown: true, headerStyle: { backgroundColor: colors.pureWhite }, headerTitleStyle: { fontFamily: fontFamily.bold, color: colors.ink }, + // Without this, the native-stack's default back button/chevron keeps its + // platform default tint (effectively invisible against a dark header — + // found.md: "kein Zurückbutton" on the health screen wasn't a missing + // button, it was a black-on-black one). + headerTintColor: colors.ink, } as const; return ( diff --git a/mobile/app/(app)/image-viewer.tsx b/mobile/app/(app)/image-viewer.tsx index bb77246..b72f80a 100644 --- a/mobile/app/(app)/image-viewer.tsx +++ b/mobile/app/(app)/image-viewer.tsx @@ -1,9 +1,64 @@ -import { View, Pressable, Image, useWindowDimensions, StyleSheet } from "react-native"; +import { useRef } from "react"; +import { View, Pressable, Image, Animated, PanResponder, useWindowDimensions, StyleSheet } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; import { X } from "lucide-react-native"; import { colors } from "../../src/theme/tokens"; +const MIN_SCALE = 1; +const MAX_SCALE = 4; + +function touchDistance(touches: { pageX: number; pageY: number }[]): number { + const [a, b] = touches; + return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY); +} + +/** + * Two-finger pinch-to-zoom on the full-size image, via a plain PanResponder + * rather than react-native-gesture-handler — that library isn't installed + * yet (see mobile/README.md's native-module rebuild notes), and a raw + * PanResponder is enough for a single scale gesture with no pan/rotate. + * Scale lives in an Animated.Value (not React state) so pinching doesn't + * re-render the tree on every touch-move event. + */ +function useZoomPanResponder() { + const scale = useRef(new Animated.Value(1)).current; + const lastScale = useRef(1); + const initialDistance = useRef(null); + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: (evt) => evt.nativeEvent.touches.length === 2, + onMoveShouldSetPanResponder: (evt) => evt.nativeEvent.touches.length === 2, + onPanResponderGrant: (evt) => { + if (evt.nativeEvent.touches.length === 2) { + initialDistance.current = touchDistance(evt.nativeEvent.touches); + } + }, + onPanResponderMove: (evt) => { + const touches = evt.nativeEvent.touches; + if (touches.length === 2 && initialDistance.current) { + const ratio = touchDistance(touches) / initialDistance.current; + const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, lastScale.current * ratio)); + scale.setValue(next); + } + }, + onPanResponderRelease: () => { + scale.stopAnimation((value) => { + lastScale.current = Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)); + scale.setValue(lastScale.current); + }); + initialDistance.current = null; + }, + onPanResponderTerminate: () => { + initialDistance.current = null; + }, + }) + ).current; + + return { scale, panResponder }; +} + /** * Full-screen "tap to enlarge" viewer for a single post image, pushed as a * route (presented as `fullScreenModal` in (app)/_layout.tsx) rather than @@ -25,12 +80,18 @@ export default function ImageViewerScreen() { const insets = useSafeAreaInsets(); const uris: string[] = JSON.parse(images); const uri = uris[Number(index) || 0]; + const { scale, panResponder } = useZoomPanResponder(); return ( router.back()}> - + router.back()} diff --git a/mobile/app/(app)/pets/[petId]/edit.tsx b/mobile/app/(app)/pets/[petId]/edit.tsx index 6408f77..203c396 100644 --- a/mobile/app/(app)/pets/[petId]/edit.tsx +++ b/mobile/app/(app)/pets/[petId]/edit.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { View, Text, Pressable, ScrollView, ActivityIndicator, Alert, StyleSheet } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; @@ -6,9 +6,10 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react-native"; import { useTRPC } from "../../../../src/api/trpc"; import { useActivePet } from "../../../../src/context/ActivePetContext"; +import { useTheme } from "../../../../src/context/ThemeContext"; import { TextField } from "../../../../src/components/TextField"; import { Button } from "../../../../src/components/Button"; -import { colors, radii, typography } from "../../../../src/theme/tokens"; +import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens"; /** * Basic edit form (name, nickname, bio, adoption story) — a subset of web's @@ -22,6 +23,8 @@ export default function EditPetScreen() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { activePetId, clearActivePetId } = useActivePet(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const { data: pet, isLoading } = useQuery(trpc.pets.byId.queryOptions({ petId })); @@ -123,22 +126,25 @@ export default function EditPetScreen() { ); } -const styles = StyleSheet.create({ - container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 }, - center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 12 }, - multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 }, - error: { ...typography.body, color: colors.alertRed, marginTop: 8 }, - deleteRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 8, - height: 48, - borderRadius: radii.lg, - borderWidth: 1, - borderColor: colors.alertRedTint, - marginTop: 24, - }, - deleteText: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily }, -}); +function makeStyles(colors: ColorTokens) { + const typography: Typography = getTypography(colors); + return StyleSheet.create({ + container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 }, + center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }, + label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 12 }, + multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 }, + error: { ...typography.body, color: colors.alertRed, marginTop: 8 }, + deleteRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + height: 48, + borderRadius: radii.lg, + borderWidth: 1, + borderColor: colors.alertRedTint, + marginTop: 24, + }, + deleteText: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily }, + }); +} diff --git a/mobile/app/(app)/pets/[petId]/followers.tsx b/mobile/app/(app)/pets/[petId]/followers.tsx index 5fe5a1a..8240c44 100644 --- a/mobile/app/(app)/pets/[petId]/followers.tsx +++ b/mobile/app/(app)/pets/[petId]/followers.tsx @@ -1,18 +1,22 @@ +import { useMemo } from "react"; import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Pressable } from "react-native"; import { useTRPC } from "../../../../src/api/trpc"; +import { useTheme } from "../../../../src/context/ThemeContext"; import { PetAvatar } from "../../../../src/components/PetAvatar"; import { formatPetName } from "../../../../src/lib/format"; -import { colors, typography } from "../../../../src/theme/tokens"; +import { type ColorTokens } from "../../../../src/theme/tokens"; export default function FollowersScreen() { const { t } = useTranslation(); const { petId } = useLocalSearchParams<{ petId: string }>(); const router = useRouter(); const trpc = useTRPC(); + const { colors, typography } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const { data, isLoading } = useQuery(trpc.follows.listFollowers.queryOptions({ petId })); if (isLoading) { @@ -44,9 +48,11 @@ export default function FollowersScreen() { ); } -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: 12 }, - row: { flexDirection: "row", alignItems: "center", gap: 12 }, -}); +function makeStyles(colors: ColorTokens) { + return StyleSheet.create({ + list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite }, + center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, + separator: { height: 12 }, + row: { flexDirection: "row", alignItems: "center", gap: 12 }, + }); +} diff --git a/mobile/app/(app)/pets/[petId]/following.tsx b/mobile/app/(app)/pets/[petId]/following.tsx index 9dd6df2..9fe9e70 100644 --- a/mobile/app/(app)/pets/[petId]/following.tsx +++ b/mobile/app/(app)/pets/[petId]/following.tsx @@ -1,18 +1,22 @@ +import { useMemo } from "react"; import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Pressable } from "react-native"; import { useTRPC } from "../../../../src/api/trpc"; +import { useTheme } from "../../../../src/context/ThemeContext"; import { PetAvatar } from "../../../../src/components/PetAvatar"; import { formatPetName } from "../../../../src/lib/format"; -import { colors, typography } from "../../../../src/theme/tokens"; +import { type ColorTokens } from "../../../../src/theme/tokens"; export default function FollowingScreen() { const { t } = useTranslation(); const { petId } = useLocalSearchParams<{ petId: string }>(); const router = useRouter(); const trpc = useTRPC(); + const { colors, typography } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const { data, isLoading } = useQuery(trpc.follows.listFollowing.queryOptions({ petId })); if (isLoading) { @@ -44,9 +48,11 @@ export default function FollowingScreen() { ); } -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: 12 }, - row: { flexDirection: "row", alignItems: "center", gap: 12 }, -}); +function makeStyles(colors: ColorTokens) { + return StyleSheet.create({ + list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite }, + center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, + separator: { height: 12 }, + row: { flexDirection: "row", alignItems: "center", gap: 12 }, + }); +} diff --git a/mobile/app/(app)/pets/[petId]/health/index.tsx b/mobile/app/(app)/pets/[petId]/health/index.tsx index ce381eb..1b1395a 100644 --- a/mobile/app/(app)/pets/[petId]/health/index.tsx +++ b/mobile/app/(app)/pets/[petId]/health/index.tsx @@ -1,10 +1,12 @@ +import { useMemo } from "react"; 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"; +import { useTheme } from "../../../../../src/context/ThemeContext"; +import { type ColorTokens } from "../../../../../src/theme/tokens"; /** * Port of web's src/app/(app)/pets/[petId]/health/page.tsx, scoped to the @@ -14,6 +16,8 @@ import { colors } from "../../../../../src/theme/tokens"; */ export default function PetHealthScreen() { const { petId } = useLocalSearchParams<{ petId: string }>(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); return ( @@ -26,6 +30,8 @@ export default function PetHealthScreen() { ); } -const styles = StyleSheet.create({ - container: { padding: 16, gap: 24, backgroundColor: colors.porchWhite, flexGrow: 1 }, -}); +function makeStyles(colors: ColorTokens) { + return StyleSheet.create({ + container: { padding: 16, gap: 24, backgroundColor: colors.porchWhite, flexGrow: 1 }, + }); +} diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx index cecda88..f456f90 100644 --- a/mobile/app/(app)/pets/[petId]/index.tsx +++ b/mobile/app/(app)/pets/[petId]/index.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; @@ -5,13 +6,14 @@ 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 { useTheme } from "../../../../src/context/ThemeContext"; import { PetAvatar } from "../../../../src/components/PetAvatar"; import { PetIdentityBadges } from "../../../../src/components/PetIdentityBadges"; import { PostCard } from "../../../../src/components/PostCard"; import { Button } from "../../../../src/components/Button"; import { FollowButton } from "../../../../src/components/FollowButton"; import { formatPetName } from "../../../../src/lib/format"; -import { colors, typography } from "../../../../src/theme/tokens"; +import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens"; /** * Port of web's src/app/(app)/pets/[petId]/page.tsx: identity, bio/adoption @@ -26,6 +28,8 @@ export default function PetProfileScreen() { const router = useRouter(); const trpc = useTRPC(); const { activePetId } = useActivePet(); + const { colors, typography } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const { data: pet, isLoading, error } = useQuery(trpc.pets.getProfile.queryOptions({ petId })); const { data: followers } = useQuery(trpc.follows.listFollowers.queryOptions({ petId })); @@ -137,24 +141,27 @@ export default function PetProfileScreen() { ); } -const styles = StyleSheet.create({ - list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite }, - center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, - errorText: { ...typography.body, color: colors.alertRed }, - header: { alignItems: "center", gap: 8, marginBottom: 16 }, - nameRow: { flexDirection: "row", alignItems: "center", marginTop: 8 }, - name: { ...typography.title }, - species: { ...typography.body, color: colors.quietGray }, - statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 }, - 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 }, - postSeparatorWrap: { paddingVertical: 16 }, - postSeparatorLine: { height: 1, backgroundColor: colors.hairlineGray }, -}); +function makeStyles(colors: ColorTokens) { + const typography: Typography = getTypography(colors); + return StyleSheet.create({ + list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite }, + center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, + errorText: { ...typography.body, color: colors.alertRed }, + header: { alignItems: "center", gap: 8, marginBottom: 16 }, + nameRow: { flexDirection: "row", alignItems: "center", marginTop: 8 }, + name: { ...typography.title }, + species: { ...typography.body, color: colors.quietGray }, + statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 }, + 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 }, + postSeparatorWrap: { paddingVertical: 16 }, + postSeparatorLine: { height: 1, backgroundColor: colors.hairlineGray }, + }); +} diff --git a/mobile/found.md b/mobile/found.md index a62a5c8..f076382 100644 --- a/mobile/found.md +++ b/mobile/found.md @@ -8,4 +8,25 @@ TODO Neu: - [x] Explore Filter ist jetzt vorhanden, der Rassen-Dropdown, schiebt die Vorhanden elemente nach unten statt darüber zu lappen. - [x] Explore Seite ist nur Halb im Darkmode - [x] Benachrichtiungsseite nicht wirklich im Darkmode -- + +TODO Neu2: +- [ ] Sprache ist Standard Englisch auch schon im Login-Screen, hier wäre bei der ersten Einwahl in die App, eine Prüfung der Systemsprache wichtig damit direkt Englisch oder Deutsch ausgewählt wird. + → Ursache: expo-localization (Geräte-Spracherkennung) ist als natives Modul noch nicht in den Dev-Client eingebunden, daher greift aktuell immer der Englisch-Fallback. Löst sich mit dem geplanten nativen Rebuild von selbst. +- [x] Im Feed habe ich als Nutzer keine Möglich einen Beitrag zu Reporten! Das wäre wichtig. Report-Funktion ist in der Webansicht vollfunktionsfähig und wird im Admin-Bereich getrackt. + → Melden-Button (⋮) auf jedem Beitrag, 5 Report-Gründe, live getestet (Übermittlung + Speicherung bestätigt). +- [x] Bilder lassen sich nicht Vergrößern mit 2 Fingern. + → Pinch-to-Zoom im Bild-Viewer ergänzt (ohne neue native Abhängigkeit). Über adb nicht als echte Zwei-Finger-Geste simulierbar, daher nur Code+Typecheck geprüft, nicht live. +- [x] Innerhal des Tierprofils + Gesundheitskarte ist der DarkMode nicht umgesetzt + → Tierprofil, Profil bearbeiten, Follower/Folgt sowie die komplette Gesundheitsseite (inkl. Einwilligungs-Screen) sind jetzt dark-mode-fähig, live bestätigt. +- [x] Gesundheitsdaten werden nicht gespeichert, sowie kein Zurückbutton sobald man fertig ist mit der bearbeitung der daten. + → Zurück-Pfeil war nur unsichtbar (schwarz auf schwarzem Header im Dark Mode), jetzt auf allen Unterseiten sichtbar. Speicherproblem lag am Datumsformat (nächster Punkt) plus fehlender Fehlermeldung bei ungültiger Eingabe — beides behoben, End-to-End getestet. +- [x] Datum wird Korrekt dargestellt, die eingabe ist aber, aktuelle ansicht JJJJ-MM-DD // korrekt wäre DD-MM-JJJJ + → Eingabefelder akzeptieren jetzt TT-MM-JJJJ, werden intern korrekt umgewandelt; ungültige Eingaben zeigen eine Fehlermeldung. +- [ ] Gewichtsdaten werden nicht als Diagramm angezeigt (feature) + → Echtes Feature, kein Bug — noch offen, Rückmeldung ob/wann gewünscht. +- [x] Technische Frage, was wird beim Feed abgerufen? Zum vergleich in der Web umgebung wird mein feed(followerbezogen) und der trend (könnte dir auch gefallen) seperat eingeblendet, wird in der App eine kombination daraus gemacht ? falls ja super. + → Nein: Mobile Feed zeigt ausschließlich den chronologischen Follower-Feed (feed.getFeed, mode "chrono"). Web's separater "Trending"-Tab (explore.getTrending, "könnte dir auch gefallen") ist in der App noch gar nicht gebaut — bekannte Lücke, bereits in der README dokumentiert. +- [x] unter meine Tiere, bitte Impressum datenschutz und agbs als link einbinden. sowie eine Versionsnummer der App + → Links + "Version 1.0.0" unterhalb der Sprachauswahl ergänzt, live getestet (Impressum öffnet im Browser). +- [x] Symbole für Deutsch Englisch und Automatisch im Meine tiere menü ändern. auf flaggen z.b. + → 🇩🇪 / 🇬🇧 / 🌐 statt Icon, live bestätigt. diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index f0918e6..625910e 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -1,14 +1,15 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { View, Text, Pressable, StyleSheet, ScrollView, useWindowDimensions } from "react-native"; import { useRouter } from "expo-router"; import { Image } from "expo-image"; -import { MessageCircle } from "lucide-react-native"; +import { MessageCircle, MoreVertical } from "lucide-react-native"; import { PetAvatar } from "./PetAvatar"; import { PetIdentityBadges } from "./PetIdentityBadges"; import { PawButton } from "./PawButton"; import { PawBackButton } from "./PawBackButton"; import { RepostCard } from "./RepostCard"; import { VideoPlayer } from "./VideoPlayer"; +import { ReportSheet } from "./ReportSheet"; import { getMediaUrl } from "../lib/media-url"; import { formatPetName, formatRelativeTime } from "../lib/format"; import { useTheme } from "../context/ThemeContext"; @@ -77,6 +78,7 @@ export function PostCard({ post, actingPetId }: Props) { const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); const cardWidth = width - 32; // matches the 16px screen padding used by the feed list + const [reportOpen, setReportOpen] = useState(false); // Reposts ("Paw-Backs") delegate to RepostCard — a read-only wrapper // around the original post, no action row (matches web). @@ -91,16 +93,27 @@ export function PostCard({ post, actingPetId }: Props) { return ( - router.push(`/pets/${post.pet.id}`)}> - - - - {formatPetName(post.pet)} - + + router.push(`/pets/${post.pet.id}`)}> + + + + {formatPetName(post.pet)} + + + {formatRelativeTime(post.createdAt)} - {formatRelativeTime(post.createdAt)} - - + + setReportOpen(true)} hitSlop={8}> + + + + setReportOpen(false)} + reporterPetId={actingPetId} + targetPostId={post.id} + /> {post.type === "VIDEO" && post.videoPost ? ( void; + reporterPetId: string; + /** Mutually exclusive — set one or the other, matching the backend's targetPostId XOR targetPetId. */ + targetPostId?: string; + targetPetId?: string; +}; + +/** + * Port of web's src/components/safety/ReportSheet.tsx (SAFE-02), trimmed for + * mobile v1: tapping a reason submits immediately instead of a separate + * radio-then-submit step, and there's no optional notes textarea yet — the + * backend already accepts one (`reports.create`'s `notes` field), so this is + * a real, if narrower, gap versus web rather than a stub. + */ +export function ReportSheet({ visible, onClose, reporterPetId, targetPostId, targetPetId }: Props) { + const { t } = useTranslation(); + const trpc = useTRPC(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); + const [pendingReason, setPendingReason] = useState(null); + + const reportMutation = useMutation( + trpc.reports.create.mutationOptions({ + onSuccess: () => { + setPendingReason(null); + onClose(); + Alert.alert(t("report.successToast")); + }, + onError: (err) => { + setPendingReason(null); + Alert.alert(err.data?.code === "CONFLICT" ? t("report.alreadyReported") : t("report.errorToast")); + }, + }) + ); + + const submit = (reason: ReportReason) => { + setPendingReason(reason); + reportMutation.mutate({ reporterPetId, targetPostId, targetPetId, reason }); + }; + + return ( + + + e.stopPropagation()}> + {t("report.title")} + {t("report.reasonQuestion")} + {REASONS.map((reason) => ( + submit(reason)} + disabled={reportMutation.isPending} + > + + {pendingReason === reason ? "…" : t(`report.reason${reason}`)} + + + ))} + + {t("report.cancel")} + + + + + ); +} + +function makeStyles(colors: ColorTokens) { + const typography: Typography = getTypography(colors); + return StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" }, + sheet: { + backgroundColor: colors.pureWhite, + borderTopLeftRadius: radii["2xl"], + borderTopRightRadius: radii["2xl"], + padding: 20, + gap: 4, + }, + title: { ...typography.title, fontSize: 18 }, + question: { ...typography.body, color: colors.quietGray, marginBottom: 8 }, + reasonRow: { paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, + reasonText: { ...typography.body }, + cancelRow: { paddingVertical: 16, alignItems: "center" }, + cancelText: { ...typography.body, color: colors.quietGray }, + }); +} diff --git a/mobile/src/components/health/HealthConsentGate.tsx b/mobile/src/components/health/HealthConsentGate.tsx index 34da013..65904b3 100644 --- a/mobile/src/components/health/HealthConsentGate.tsx +++ b/mobile/src/components/health/HealthConsentGate.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { View, Text, Pressable, ActivityIndicator, Linking, Alert, StyleSheet } from "react-native"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -7,7 +7,8 @@ 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"; +import { useTheme } from "../../context/ThemeContext"; +import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; type Props = { petId: string; @@ -25,6 +26,8 @@ export function HealthConsentGate({ petId, children }: Props) { const trpc = useTRPC(); const queryClient = useQueryClient(); const router = useRouter(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const [checked, setChecked] = useState(false); const { data: status, isLoading } = useQuery(trpc.health.getConsentStatus.queryOptions({ petId })); @@ -123,41 +126,44 @@ export function HealthConsentGate({ petId, children }: Props) { ); } -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 }, -}); +function makeStyles(colors: ColorTokens) { + const typography: Typography = getTypography(colors); + return 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 }, + }); +} diff --git a/mobile/src/components/health/VaccineSection.tsx b/mobile/src/components/health/VaccineSection.tsx index b385bf8..5104b90 100644 --- a/mobile/src/components/health/VaccineSection.tsx +++ b/mobile/src/components/health/VaccineSection.tsx @@ -1,24 +1,26 @@ -import { useState } from "react"; -import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native"; +import { useMemo, useState } from "react"; +import { View, Text, Pressable, ActivityIndicator, Alert, StyleSheet } from "react-native"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react-native"; import { useTRPC } from "../../api/trpc"; +import { useTheme } from "../../context/ThemeContext"; 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); +import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; +import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format"; /** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */ export function VaccineSection({ petId }: { petId: string }) { const { t, i18n } = useTranslation(); const trpc = useTRPC(); const queryClient = useQueryClient(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const [showForm, setShowForm] = useState(false); const [name, setName] = useState(""); - const [dateGiven, setDateGiven] = useState(today()); + const [dateGiven, setDateGiven] = useState(todayAsDisplayDate()); const [nextDueDate, setNextDueDate] = useState(""); const [notes, setNotes] = useState(""); @@ -35,15 +37,37 @@ export function VaccineSection({ petId }: { petId: string }) { setName(""); setNextDueDate(""); setNotes(""); - setDateGiven(today()); + setDateGiven(todayAsDisplayDate()); }, + onError: () => Alert.alert(t("health.saveError")), }) ); const deleteMutation = useMutation(trpc.health.deleteVaccine.mutationOptions({ onSuccess: invalidate })); - const canSubmit = name.trim().length > 0 && dateGiven.length > 0 && !addMutation.isPending; + const canSubmit = name.trim().length > 0 && !!displayDateToIso(dateGiven) && !addMutation.isPending; const isOverdue = (nextDue: string | null) => !!nextDue && new Date(nextDue).getTime() < Date.now(); + const handleSubmit = () => { + const isoDateGiven = displayDateToIso(dateGiven); + if (!isoDateGiven) { + Alert.alert(t("health.invalidDate")); + return; + } + const trimmedNextDue = nextDueDate.trim(); + const isoNextDue = trimmedNextDue ? displayDateToIso(trimmedNextDue) : null; + if (trimmedNextDue && !isoNextDue) { + Alert.alert(t("health.invalidDate")); + return; + } + addMutation.mutate({ + petId, + name: name.trim(), + dateGiven: isoDateGiven, + nextDueDate: isoNextDue ?? undefined, + notes: notes.trim() || undefined, + }); + }; + return ( @@ -62,15 +86,7 @@ export function VaccineSection({ petId }: { petId: string }) {