fix(mobile): found.md batch — reports, dark mode, health form fixes

- 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).
This commit is contained in:
admin committed 2026-09-06 18:55:12 +02:00
1 parent 462f295caa
commit 4a35fce20b
19 files changed
+615 -214

No files matched your search

+38 -6
View File
@@ -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() {
<>
<AppearanceSection />
<LanguageSection />
<LegalSection />
</>
}
/>
@@ -164,9 +167,9 @@ function LanguageSection() {
const [preference, setPreferenceState] = useState<Locale | null>(() => 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)}
>
<Languages color={active ? colors.pureWhite : colors.ink} size={18} />
<Text style={styles.flagEmoji}>{option.flag}</Text>
<Text style={[styles.appearanceOptionText, active && styles.appearanceOptionTextActive]}>
{option.label}
</Text>
@@ -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 (
<View style={styles.legalSection}>
{links.map((link) => (
<Pressable key={link.path} onPress={() => Linking.openURL(`${env.apiUrl}/${link.path}`)}>
<Text style={styles.legalLink}>{link.label}</Text>
</Pressable>
))}
<Text style={styles.versionText}>{t("pets.versionLabel", { version })}</Text>
</View>
);
}
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 },
});
}
+5
View File
@@ -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 (
+63 -2
View File
@@ -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<number | null>(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 (
<Pressable style={styles.backdrop} onPress={() => router.back()}>
<Image source={{ uri }} style={StyleSheet.absoluteFill} resizeMode="cover" blurRadius={40} />
<View style={[StyleSheet.absoluteFill, styles.scrim]} />
<Image source={{ uri }} style={{ width, height }} resizeMode="contain" />
<Animated.Image
source={{ uri }}
style={{ width, height, transform: [{ scale }] }}
resizeMode="contain"
{...panResponder.panHandlers}
/>
<Pressable
style={[styles.closeButton, { top: insets.top + 8 }]}
onPress={() => router.back()}
+27 -21
View File
@@ -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 },
});
}
+13 -7
View File
@@ -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 },
});
}
+13 -7
View File
@@ -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 },
});
}
+10 -4
View File
@@ -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 (
<ScrollView contentContainerStyle={styles.container}>
@@ -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 },
});
}
+29 -22
View File
@@ -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 },
});
}
+22 -1
View File
@@ -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.
+27 -12
View File
@@ -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 (
<View style={styles.card}>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
<View style={styles.headerRow}>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
</View>
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
</View>
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
</View>
</Pressable>
</Pressable>
<Pressable style={styles.moreButton} onPress={() => setReportOpen(true)} hitSlop={8}>
<MoreVertical size={18} color={colors.quietGray} />
</Pressable>
</View>
<ReportSheet
visible={reportOpen}
onClose={() => setReportOpen(false)}
reporterPetId={actingPetId}
targetPostId={post.id}
/>
{post.type === "VIDEO" && post.videoPost ? (
<VideoPlayer
@@ -170,7 +183,9 @@ function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
card: { gap: 10 },
header: { flexDirection: "row", alignItems: "center", gap: 10 },
headerRow: { flexDirection: "row", alignItems: "center" },
header: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
moreButton: { padding: 4 },
headerText: { flex: 1 },
nameRow: { flexDirection: "row", alignItems: "center" },
name: { ...typography.body, fontFamily: typography.title.fontFamily },
+100
View File
@@ -0,0 +1,100 @@
import { useMemo, useState } from "react";
import { Modal, View, Text, Pressable, Alert, StyleSheet } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useTRPC } from "../api/trpc";
import { useTheme } from "../context/ThemeContext";
import { getTypography, radii, type ColorTokens, type Typography } from "../theme/tokens";
type ReportReason = "SPAM" | "HARASSMENT" | "INAPPROPRIATE" | "MISINFORMATION" | "OTHER";
const REASONS: ReportReason[] = ["SPAM", "HARASSMENT", "INAPPROPRIATE", "MISINFORMATION", "OTHER"];
type Props = {
visible: boolean;
onClose: () => 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<ReportReason | null>(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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
<Text style={styles.title}>{t("report.title")}</Text>
<Text style={styles.question}>{t("report.reasonQuestion")}</Text>
{REASONS.map((reason) => (
<Pressable
key={reason}
style={styles.reasonRow}
onPress={() => submit(reason)}
disabled={reportMutation.isPending}
>
<Text style={styles.reasonText}>
{pendingReason === reason ? "…" : t(`report.reason${reason}`)}
</Text>
</Pressable>
))}
<Pressable style={styles.cancelRow} onPress={onClose} disabled={reportMutation.isPending}>
<Text style={styles.cancelText}>{t("report.cancel")}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
}
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 },
});
}
@@ -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 },
});
}
+48 -29
View File
@@ -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 (
<View style={styles.section}>
<View style={styles.sectionHeader}>
@@ -62,15 +86,7 @@ export function VaccineSection({ petId }: { petId: string }) {
<Button
title={addMutation.isPending ? "…" : t("common.save")}
disabled={!canSubmit}
onPress={() =>
addMutation.mutate({
petId,
name: name.trim(),
dateGiven,
nextDueDate: nextDueDate.trim() || undefined,
notes: notes.trim() || undefined,
})
}
onPress={handleSubmit}
/>
</Card>
)}
@@ -105,15 +121,18 @@ export function VaccineSection({ petId }: { petId: string }) {
);
}
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 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return 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 },
});
}
@@ -1,23 +1,25 @@
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 vetVisit CRUD — see health/page.tsx's Tierarzt tab. */
export function VetVisitSection({ 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 [date, setDate] = useState(today());
const [date, setDate] = useState(todayAsDisplayDate());
const [reason, setReason] = useState("");
const [vetName, setVetName] = useState("");
const [notes, setNotes] = useState("");
@@ -35,13 +37,29 @@ export function VetVisitSection({ petId }: { petId: string }) {
setReason("");
setVetName("");
setNotes("");
setDate(today());
setDate(todayAsDisplayDate());
},
onError: () => Alert.alert(t("health.saveError")),
})
);
const deleteMutation = useMutation(trpc.health.deleteVetVisit.mutationOptions({ onSuccess: invalidate }));
const canSubmit = date.length > 0 && reason.trim().length > 0 && !addMutation.isPending;
const canSubmit = !!displayDateToIso(date) && reason.trim().length > 0 && !addMutation.isPending;
const handleSubmit = () => {
const isoDate = displayDateToIso(date);
if (!isoDate) {
Alert.alert(t("health.invalidDate"));
return;
}
addMutation.mutate({
petId,
date: isoDate,
reason: reason.trim(),
vetName: vetName.trim() || undefined,
notes: notes.trim() || undefined,
});
};
return (
<View style={styles.section}>
@@ -61,15 +79,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
<Button
title={addMutation.isPending ? "…" : t("common.save")}
disabled={!canSubmit}
onPress={() =>
addMutation.mutate({
petId,
date,
reason: reason.trim(),
vetName: vetName.trim() || undefined,
notes: notes.trim() || undefined,
})
}
onPress={handleSubmit}
/>
</Card>
)}
@@ -99,15 +109,18 @@ export function VetVisitSection({ petId }: { petId: string }) {
);
}
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 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return 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 },
});
}
@@ -1,23 +1,25 @@
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 weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */
export function WeightLogSection({ 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 [date, setDate] = useState(today());
const [date, setDate] = useState(todayAsDisplayDate());
const [weightKg, setWeightKg] = useState("");
const [notes, setNotes] = useState("");
@@ -33,14 +35,24 @@ export function WeightLogSection({ petId }: { petId: string }) {
setShowForm(false);
setWeightKg("");
setNotes("");
setDate(today());
setDate(todayAsDisplayDate());
},
onError: () => Alert.alert(t("health.saveError")),
})
);
const deleteMutation = useMutation(trpc.health.deleteWeightLog.mutationOptions({ onSuccess: invalidate }));
const parsedWeight = Number(weightKg.replace(",", "."));
const canSubmit = date.length > 0 && parsedWeight > 0 && !addMutation.isPending;
const canSubmit = !!displayDateToIso(date) && parsedWeight > 0 && !addMutation.isPending;
const handleSubmit = () => {
const isoDate = displayDateToIso(date);
if (!isoDate) {
Alert.alert(t("health.invalidDate"));
return;
}
addMutation.mutate({ petId, date: isoDate, weightKg: parsedWeight, notes: notes.trim() || undefined });
};
return (
<View style={styles.section}>
@@ -59,7 +71,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
<Button
title={addMutation.isPending ? "…" : t("common.save")}
disabled={!canSubmit}
onPress={() => addMutation.mutate({ petId, date, weightKg: parsedWeight, notes: notes.trim() || undefined })}
onPress={handleSubmit}
/>
</Card>
)}
@@ -88,14 +100,17 @@ export function WeightLogSection({ petId }: { petId: string }) {
);
}
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 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return 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 },
});
}
+10 -6
View File
@@ -1,6 +1,5 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import * as Localization from "expo-localization";
import { storage } from "../lib/storage";
import en from "./locales/en.json";
import de from "./locales/de.json";
@@ -20,14 +19,19 @@ function isLocale(value: string | null | undefined): value is Locale {
/**
* First supported locale among the device's language list, else defaultLocale.
* expo-localization is a native module added via config plugin — on a
* dev-client binary built before this change, it isn't linked yet and
* getLocales() throws "Cannot find native module 'ExpoLocalization'" rather
* than returning an empty array. Falling back to defaultLocale here (instead
* of letting that throw during i18next.init's module-load-time call) is what
* keeps the app bootable on an un-rebuilt dev client — see mobile/README.md.
* dev-client binary built before this change, it isn't linked yet, and
* merely *importing* the package (not just calling getLocales()) throws
* "Cannot find native module 'ExpoLocalization'" at module-evaluation time.
* A static top-level `import` gets hoisted by Metro to the top of this
* module's compiled output, outside any try/catch — so the require has to
* happen lazily, inside this function's own try block, to actually be
* catchable. Falling back to defaultLocale here is what keeps the app
* bootable on an un-rebuilt dev client — see mobile/README.md.
*/
function deviceLocale(): Locale {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- must be a runtime require, not a static import; see comment above
const Localization = require("expo-localization") as typeof import("expo-localization");
const match = Localization.getLocales().find((l) => isLocale(l.languageCode));
return isLocale(match?.languageCode) ? match.languageCode : defaultLocale;
} catch {
+23 -4
View File
@@ -22,6 +22,19 @@
"noResultsFound": "Keine Einträge gefunden.",
"edit": "Bearbeiten"
},
"report": {
"title": "Melden",
"reasonQuestion": "Warum meldest du das?",
"reasonSPAM": "Spam",
"reasonHARASSMENT": "Belästigung oder Mobbing",
"reasonINAPPROPRIATE": "Unangemessene Inhalte",
"reasonMISINFORMATION": "Falsche oder irreführende Informationen",
"reasonOTHER": "Etwas anderes",
"cancel": "Nicht melden",
"successToast": "Meldung übermittelt. Wir prüfen sie in Kürze.",
"errorToast": "Deine Meldung konnte nicht übermittelt werden. Bitte versuch es erneut.",
"alreadyReported": "Du hast das schon gemeldet."
},
"social": {
"follow": "Folgen",
"unfollow": "Entfolgen",
@@ -96,6 +109,7 @@
"shareButton": "Meilenstein teilen"
},
"health": {
"saveError": "Eintrag konnte nicht gespeichert werden. Bitte prüfe deine Eingaben.",
"consentSaveError": "Einwilligung konnte nicht gespeichert werden.",
"consentRevokeError": "Einwilligung konnte nicht widerrufen werden.",
"revokeConfirmTitle": "Einwilligung wirklich widerrufen?",
@@ -110,18 +124,19 @@
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
"revokeLink": "Widerrufen",
"weightTitle": "Gewicht",
"weightDatePlaceholder": "JJJJ-MM-TT",
"weightDatePlaceholder": "TT-MM-JJJJ",
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
"notesPlaceholder": "Notizen (optional)",
"weightEmpty": "Noch keine Gewichtseinträge",
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben.",
"vetTitle": "Tierarzt",
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
"vetEmpty": "Noch keine Tierarztbesuche",
"vaccineTitle": "Impfungen",
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
"vaccineGivenPlaceholder": "Gegeben am (JJJJ-MM-TT)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, JJJJ-MM-TT)",
"vaccineGivenPlaceholder": "Gegeben am (TT-MM-JJJJ)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT-MM-JJJJ)",
"vaccineEmpty": "Noch keine Impfungen eingetragen",
"vaccineGiven": "Gegeben {{date}}",
"vaccineDue": "Fällig {{date}}",
@@ -177,7 +192,11 @@
"languageTitle": "Sprache",
"languageGerman": "Deutsch",
"languageEnglish": "English",
"languageAuto": "Automatisch"
"languageAuto": "Automatisch",
"legalImpressum": "Impressum",
"legalPrivacy": "Datenschutzerklärung",
"legalTerms": "Nutzungsbedingungen",
"versionLabel": "Version {{version}}"
},
"auth": {
"signInTitle": "PawFeed Login",
+23 -4
View File
@@ -22,6 +22,19 @@
"noResultsFound": "No entries found.",
"edit": "Edit"
},
"report": {
"title": "Report",
"reasonQuestion": "Why are you reporting this?",
"reasonSPAM": "Spam",
"reasonHARASSMENT": "Harassment or bullying",
"reasonINAPPROPRIATE": "Inappropriate content",
"reasonMISINFORMATION": "False or misleading information",
"reasonOTHER": "Something else",
"cancel": "Don't report",
"successToast": "Report submitted. We'll review it shortly.",
"errorToast": "Couldn't submit your report. Please try again.",
"alreadyReported": "You've already reported this."
},
"social": {
"follow": "Follow",
"unfollow": "Unfollow",
@@ -96,6 +109,7 @@
"shareButton": "Share milestone"
},
"health": {
"saveError": "Couldn't save this entry. Please check your input.",
"consentSaveError": "Consent could not be saved.",
"consentRevokeError": "Consent could not be revoked.",
"revokeConfirmTitle": "Really revoke consent?",
@@ -110,18 +124,19 @@
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
"revokeLink": "Revoke",
"weightTitle": "Weight",
"weightDatePlaceholder": "YYYY-MM-DD",
"weightDatePlaceholder": "DD-MM-YYYY",
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
"notesPlaceholder": "Notes (optional)",
"weightEmpty": "No weight entries yet",
"invalidDate": "Please enter a valid date in DD-MM-YYYY format.",
"vetTitle": "Vet",
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
"vetNamePlaceholder": "Vet / clinic (optional)",
"vetEmpty": "No vet visits yet",
"vaccineTitle": "Vaccinations",
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
"vaccineGivenPlaceholder": "Given on (YYYY-MM-DD)",
"vaccineNextDuePlaceholder": "Next due (optional, YYYY-MM-DD)",
"vaccineGivenPlaceholder": "Given on (DD-MM-YYYY)",
"vaccineNextDuePlaceholder": "Next due (optional, DD-MM-YYYY)",
"vaccineEmpty": "No vaccinations recorded yet",
"vaccineGiven": "Given {{date}}",
"vaccineDue": "Due {{date}}",
@@ -177,7 +192,11 @@
"languageTitle": "Language",
"languageGerman": "Deutsch",
"languageEnglish": "English",
"languageAuto": "Automatic"
"languageAuto": "Automatic",
"legalImpressum": "Legal Notice",
"legalPrivacy": "Privacy Policy",
"legalTerms": "Terms of Service",
"versionLabel": "Version {{version}}"
},
"auth": {
"signInTitle": "PawFeed Login",
+41
View File
@@ -0,0 +1,41 @@
/**
* The health forms take a free-text date field validated against the
* backend's strict `YYYY-MM-DD` regex (src/trpc/routers/health.ts). Typing
* dates in that order isn't the German convention (DD-MM-YYYY) — found.md:
* users typing the natural order got a validation error the mutations never
* surfaced (no onError handler), which looked exactly like "data isn't being
* saved". Both problems share one fix: accept DD-MM-YYYY in the UI, convert
* to ISO right before the mutation call, and reject anything that doesn't
* parse as a real calendar date instead of silently sending garbage.
*/
const DISPLAY_PATTERN = /^(\d{2})-(\d{2})-(\d{4})$/;
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
export function isoToDisplayDate(iso: string): string {
const match = ISO_PATTERN.exec(iso);
if (!match) return iso;
const [, year, month, day] = match;
return `${day}-${month}-${year}`;
}
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD-MM-YYYY calendar date. */
export function displayDateToIso(display: string): string | null {
const match = DISPLAY_PATTERN.exec(display.trim());
if (!match) return null;
const [, dayStr, monthStr, yearStr] = match;
const day = Number(dayStr);
const month = Number(monthStr);
const year = Number(yearStr);
const date = new Date(Date.UTC(year, month - 1, day));
const isRealDate =
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
if (!isRealDate) return null;
return `${yearStr}-${monthStr}-${dayStr}`;
}
export function todayAsDisplayDate(): string {
return isoToDisplayDate(new Date().toISOString().slice(0, 10));
}