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 }) {