diff --git a/mobile/app/(app)/(tabs)/index.tsx b/mobile/app/(app)/(tabs)/index.tsx
index 4742b68..a2b5c5c 100644
--- a/mobile/app/(app)/(tabs)/index.tsx
+++ b/mobile/app/(app)/(tabs)/index.tsx
@@ -8,6 +8,7 @@ import { useTheme } from "../../../src/context/ThemeContext";
import { PostCard } from "../../../src/components/PostCard";
import { StoryTray } from "../../../src/components/StoryTray";
import { interleaveTrending } from "../../../src/lib/interleave-trending";
+import { useVisiblePostIds } from "../../../src/hooks/useVisiblePostIds";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { FeedPost, TrendingPost } from "../../../src/api/types";
@@ -28,6 +29,7 @@ export default function FeedScreen() {
// Fixed once per screen mount so trending posts don't jump to new spots
// as more feed pages load — see interleaveTrending's own comment.
const spacingSeed = useRef(Array.from({ length: 20 }, () => 4 + Math.floor(Math.random() * 3))).current;
+ const { visibleIds, viewabilityConfigCallbackPairs } = useVisiblePostIds();
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch, isRefetching, error } =
useInfiniteQuery({
@@ -78,8 +80,14 @@ export default function FeedScreen() {
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
-
+
)}
+ viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
ListHeaderComponent={}
ItemSeparatorComponent={() => (
diff --git a/mobile/app/(app)/milestone-new.tsx b/mobile/app/(app)/milestone-new.tsx
index 4d8a797..c7675fd 100644
--- a/mobile/app/(app)/milestone-new.tsx
+++ b/mobile/app/(app)/milestone-new.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { View, Text, Image as RNImage, Pressable, ScrollView, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
@@ -7,11 +7,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
+import { useTheme } from "../../src/context/ThemeContext";
import { uploadImageToSupabase } from "../../src/lib/upload";
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
import { TextField } from "../../src/components/TextField";
import { Button } from "../../src/components/Button";
-import { colors, radii, typography } from "../../src/theme/tokens";
+import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
@@ -27,6 +28,8 @@ export default function NewMilestoneScreen() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
+ const { colors } = useTheme();
+ const styles = useMemo(() => makeStyles(colors), [colors]);
const [milestoneType, setMilestoneType] = useState("BIRTHDAY");
const [notes, setNotes] = useState("");
@@ -135,7 +138,9 @@ export default function NewMilestoneScreen() {
);
}
-const styles = StyleSheet.create({
+function makeStyles(colors: ColorTokens) {
+ const typography = getTypography(colors);
+ return StyleSheet.create({
container: { padding: 16, gap: 14, backgroundColor: colors.porchWhite, flexGrow: 1 },
label: { ...typography.label, textTransform: "none", letterSpacing: 0 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
@@ -166,4 +171,5 @@ const styles = StyleSheet.create({
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", marginTop: 6 },
notes: { height: 88, textAlignVertical: "top", paddingTop: 12 },
error: { ...typography.body, color: colors.alertRed },
-});
+ });
+}
diff --git a/mobile/app/(app)/pets/[petId]/edit.tsx b/mobile/app/(app)/pets/[petId]/edit.tsx
index 203c396..1d85a7c 100644
--- a/mobile/app/(app)/pets/[petId]/edit.tsx
+++ b/mobile/app/(app)/pets/[petId]/edit.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
-import { View, Text, Pressable, ScrollView, ActivityIndicator, Alert, StyleSheet } from "react-native";
+import { View, Text, Pressable, FlatList, ActivityIndicator, Alert, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -9,12 +9,16 @@ 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 { BreedPicker } from "../../../../src/components/pet/BreedPicker";
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
+import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
/**
- * Basic edit form (name, nickname, bio, adoption story) — a subset of web's
- * src/app/(app)/pets/[petId]/edit/page.tsx. Breed/DM-policy/follower-
- * visibility/birthday/avatar upload aren't editable here yet.
+ * Edit form (name, nickname, breed, bio, adoption story, birthday, adoption
+ * date) — a subset of web's src/app/(app)/pets/[petId]/edit/page.tsx.
+ * DM-policy/follower-visibility/avatar upload aren't editable here yet.
+ * Species itself stays fixed (only its breed is re-selectable) — found.md
+ * asked specifically for breed correction, not a full species change.
*/
export default function EditPetScreen() {
const { t } = useTranslation();
@@ -32,6 +36,10 @@ export default function EditPetScreen() {
const [nickname, setNickname] = useState("");
const [bio, setBio] = useState("");
const [adoptionStory, setAdoptionStory] = useState("");
+ const [breedId, setBreedId] = useState(null);
+ const [breedName, setBreedName] = useState(null);
+ const [birthday, setBirthday] = useState("");
+ const [adoptedAt, setAdoptedAt] = useState("");
const [error, setError] = useState(null);
useEffect(() => {
@@ -40,6 +48,10 @@ export default function EditPetScreen() {
setNickname(pet.nickname ?? "");
setBio(pet.bio ?? "");
setAdoptionStory(pet.adoptionStory ?? "");
+ setBreedId(pet.breed?.id ?? null);
+ setBreedName(pet.breed?.name ?? null);
+ setBirthday(pet.birthday ? isoToDisplayDate(String(pet.birthday).slice(0, 10)) : "");
+ setAdoptedAt(pet.adoptedAt ? isoToDisplayDate(String(pet.adoptedAt).slice(0, 10)) : "");
}, [pet]);
const updateMutation = useMutation(
@@ -52,6 +64,26 @@ export default function EditPetScreen() {
})
);
+ const handleSave = () => {
+ const isoBirthday = birthday.trim() ? displayDateToIso(birthday.trim()) : null;
+ const isoAdoptedAt = adoptedAt.trim() ? displayDateToIso(adoptedAt.trim()) : null;
+ if ((birthday.trim() && !isoBirthday) || (adoptedAt.trim() && !isoAdoptedAt)) {
+ setError(t("editPet.invalidDate"));
+ return;
+ }
+ setError(null);
+ updateMutation.mutate({
+ petId,
+ name,
+ nickname: nickname || null,
+ breedId,
+ bio: bio || null,
+ adoptionStory: adoptionStory || null,
+ birthday: isoBirthday,
+ adoptedAt: isoAdoptedAt,
+ });
+ };
+
const deleteMutation = useMutation(
trpc.pets.delete.mutationOptions({
onSuccess: async () => {
@@ -83,49 +115,67 @@ export default function EditPetScreen() {
}
return (
-
- {t("editPet.nameLabel")}
-
+ // FlatList, not ScrollView: BreedPicker nests its own vertical
+ // ScrollView, and two same-axis ScrollViews stacked inside each other
+ // silently eat the inner one's scroll gesture on Android — see
+ // onboarding/pet.tsx's identical fix for the same OptionPicker-based
+ // component.
+
+ {t("editPet.nameLabel")}
+
- {t("editPet.nicknameLabel")}
-
+ {t("editPet.nicknameLabel")}
+
- {t("editPet.bioLabel")}
-
+ {t("editPet.breedLabel")}
+ {
+ setBreedId(o.id);
+ setBreedName(o.name);
+ }}
+ />
- {t("petForm.adoptionStory")}
-
+ {t("editPet.bioLabel")}
+
- {error ? {error} : null}
+ {t("petForm.adoptionStory")}
+
-
+ {t("editPet.adoptedAtLabel")}
+
+
+ {error ? {error} : null}
+
+
+
+
+
+ {t("editPet.deletePet")}
+
+ >
+ }
+ />
);
}
+const EMPTY_DATA: never[] = [];
+
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx
index f456f90..778128f 100644
--- a/mobile/app/(app)/pets/[petId]/index.tsx
+++ b/mobile/app/(app)/pets/[petId]/index.tsx
@@ -12,7 +12,9 @@ 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 { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
import { formatPetName } from "../../../../src/lib/format";
+import { getAvatarUrl } from "../../../../src/lib/media-url";
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
/**
@@ -30,6 +32,7 @@ export default function PetProfileScreen() {
const { activePetId } = useActivePet();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
+ const { visibleIds, viewabilityConfigCallbackPairs } = useVisiblePostIds();
const { data: pet, isLoading, error } = useQuery(trpc.pets.getProfile.queryOptions({ petId }));
const { data: followers } = useQuery(trpc.follows.listFollowers.queryOptions({ petId }));
@@ -67,7 +70,10 @@ export default function PetProfileScreen() {
data={posts}
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
- renderItem={({ item }) => }
+ renderItem={({ item }) => (
+
+ )}
+ viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
ItemSeparatorComponent={() => (
@@ -75,7 +81,20 @@ export default function PetProfileScreen() {
)}
ListHeaderComponent={
-
+ {pet.avatarKey ? (
+
+ router.push({
+ pathname: "/image-viewer",
+ params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
+ })
+ }
+ >
+
+
+ ) : (
+
+ )}
{formatPetName(pet)}
diff --git a/mobile/app/(app)/pets/[petId]/milestones.tsx b/mobile/app/(app)/pets/[petId]/milestones.tsx
index bdfa463..7d4fc0f 100644
--- a/mobile/app/(app)/pets/[petId]/milestones.tsx
+++ b/mobile/app/(app)/pets/[petId]/milestones.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";
@@ -6,9 +7,10 @@ import { Image } from "expo-image";
import { Plus } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
+import { useTheme } from "../../../../src/context/ThemeContext";
import { getMediaUrl } from "../../../../src/lib/media-url";
import { MILESTONE_META, type MilestoneType } from "../../../../src/lib/milestone-meta";
-import { colors, radii, typography } from "../../../../src/theme/tokens";
+import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import type { MilestonePost } from "../../../../src/api/types";
/**
@@ -21,6 +23,8 @@ export default function MilestonesScreen() {
const router = useRouter();
const { activePetId } = useActivePet();
const trpc = useTRPC();
+ const { colors, typography } = useTheme();
+ const styles = useMemo(() => makeStyles(colors), [colors]);
const isOwnPet = activePetId === petId;
const { data: milestones = [], isLoading } = useQuery(trpc.milestones.listByPet.queryOptions({ petId }));
@@ -59,6 +63,8 @@ export default function MilestonesScreen() {
function MilestoneRow({ post }: { post: MilestonePost }) {
const { t, i18n } = useTranslation();
+ const { colors } = useTheme();
+ const styles = useMemo(() => makeStyles(colors), [colors]);
if (!post.milestone) return null;
const meta = MILESTONE_META[post.milestone.milestoneType as MilestoneType];
const Icon = meta.icon;
@@ -79,7 +85,9 @@ function MilestoneRow({ post }: { post: MilestonePost }) {
);
}
-const styles = StyleSheet.create({
+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 },
separator: { height: 14 },
@@ -99,4 +107,5 @@ const styles = StyleSheet.create({
thumb: { width: 48, height: 48, borderRadius: radii.md },
addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 },
addButtonText: { ...typography.body, color: colors.marigoldOrange, fontFamily: typography.title.fontFamily },
-});
+ });
+}
diff --git a/mobile/found.md b/mobile/found.md
index 12e4e9b..56ea4fd 100644
--- a/mobile/found.md
+++ b/mobile/found.md
@@ -42,10 +42,15 @@ TODO Neu2:
- [x] Tier Anlegen ist nicht im Darkmode + Rassen werden als Dropdown angezeigt lassen sich aber nicht scrollen.
→ Darkmode: PetCreateForm.tsx + onboarding/pet.tsx nutzten noch die statischen (Hell-only) `colors`/`typography` statt `useTheme()` — umgestellt auf das gleiche Muster wie sign-up.tsx/OptionPicker.tsx. Scroll-Bug: onboarding/pet.tsx nutzte als äußeren Container ein normales `ScrollView`, darin verschachtelt der Rassen-Dropdown (OptionPicker) sein eigenes `ScrollView` — zwei gleichachsige ScrollViews ineinander verschlucken auf Android die Scroll-Geste des inneren, auch mit `nestedScrollEnabled`. Fix: äußeren Container auf `FlatList` (leere Liste + `ListHeaderComponent`) umgestellt, exakt das bereits funktionierende Muster aus explore.tsx für dieselbe OptionPicker-Komponente. Typecheck + Lint sauber, i18n-Parität unverändert (243/243) — nicht live auf Android getestet (kein Emulator in dieser Session), nur strukturell gegen den bekannten Explore-Fix abgeglichen.
- [x] Nach dem Speichern des ersten Tiers, und dem Hochladen des Profilfotos, wird man wieder zurück geworfen auf Tier erstellen. erst ein neustart der app lässt einen auf den feed zugreifen. (BUG)
- → Ursache: `pets.create`-Mutation in PetCreateForm.tsx hat die `trpc.pets.list`-Query (ActivePetContext) nie invalidiert. Nach dem Avatar-Schritt landet man in `(app)`, aber `OnboardingRedirect` (app/_layout.tsx) sah dort weiterhin den alten, leeren Pets-Stand aus dem Cache und schickte sofort wieder zurück nach `/onboarding/pet` — nur ein Neustart hat die Liste frisch geladen. Fix: `queryClient.invalidateQueries` auf `trpc.pets.list` direkt im `onSuccess` der Create-Mutation. Typecheck sauber. videos die angeklickt werden, laufen in endlosschleife weiter obwohl man im feed weiter scrollt
- - Profilbilder sind nicht vergrößerbar
- - Profilbearbeiten, lässt sich die Rasse nicht mehr ändern; wäre wichtig wenn man bei der erstellung fehler gemacht hat.
- - sowie kann man beim Tier kein geburtstag hinterlegen oder adoptionstag wie es im web vorhanden ist.
- - Meilensteine sind nicht im Darkmode verfügbar
- -
- -
\ No newline at end of file
+ → Ursache: `pets.create`-Mutation in PetCreateForm.tsx hat die `trpc.pets.list`-Query (ActivePetContext) nie invalidiert. Nach dem Avatar-Schritt landet man in `(app)`, aber `OnboardingRedirect` (app/_layout.tsx) sah dort weiterhin den alten, leeren Pets-Stand aus dem Cache und schickte sofort wieder zurück nach `/onboarding/pet` — nur ein Neustart hat die Liste frisch geladen. Fix: `queryClient.invalidateQueries` auf `trpc.pets.list` direkt im `onSuccess` der Create-Mutation. Typecheck sauber.
+
+TODO Neu3:
+- [x] Videos die angeklickt werden, laufen in Endlosschleife weiter obwohl man im Feed weiter scrollt.
+ → expo-video hat kein eingebautes "bin ich noch sichtbar"-Bewusstsein; der Player lief nach dem Wegscrollen einfach weiter (mit `loop = true`). Neuer Hook `useVisiblePostIds` (FlatList-`viewabilityConfigCallbackPairs`) trackt sichtbare Post-IDs, `isVisible`-Prop läuft durch PostCard → VideoPlayer, der beim Verschwinden `player.pause()` aufruft. In Feed UND Tierprofil-Postliste verdrahtet (beide nutzen VideoPlayer). Typecheck + Lint sauber, nicht live auf Android getestet (kein Emulator in dieser Session).
+- [x] Profilbilder sind nicht vergrößerbar
+ → Das Tierprofilbild (Tierprofil-Kopfbereich, size="lg") war reines, nicht antippbares ``. Öffnet jetzt bei Vorhandensein eines Avatars den bereits vorhandenen `/image-viewer` (denselben Viewer inkl. Pinch-to-Zoom, den Beitragsbilder schon nutzen) mit dem vollen Avatar-Bild. Ohne hinterlegtes Foto (Initialen-Fallback) bleibt es unangetippt, da nichts zu vergrößern ist. Typecheck + Lint sauber. Nicht live getestet (kein Emulator in dieser Session).
+- [x] Profilbearbeiten, lässt sich die Rasse nicht mehr ändern; wäre wichtig wenn man bei der Erstellung Fehler gemacht hat.
+- [x] Sowie kann man beim Tier kein Geburtstag hinterlegen oder Adoptionstag wie es im Web vorhanden ist.
+ → Beides war nur eine Mobile-UI-Lücke, das Backend (`pets.update`) unterstützt `breedId`/`birthday`/`adoptedAt` bereits (Web nutzt es längst). Rassen-Dropdown aus PetCreateForm.tsx in eine geteilte `BreedPicker`-Komponente ausgelagert (DRY) und in edit.tsx eingebaut (Tierart selbst bleibt fix, nur die Rasse ist neu wählbar). Geburtstag/Adoptionstag als TT-MM-JJJJ-Textfelder ergänzt, gleiches Validierungs-/Konvertierungsmuster wie bei den Health-Formularen (`date-format.ts`). Da BreedPicker wie zuvor ein eigenes ScrollView verschachtelt, wurde edit.tsx aus demselben Grund wie onboarding/pet.tsx von `ScrollView` auf `FlatList` umgestellt. Typecheck + Lint sauber (ein vorbestehender, nicht selbst verursachter `set-state-in-effect`-Lint-Hinweis in der bereits vorhandenen Effect besteht unverändert fort), i18n-Parität 247/247. Nicht live getestet (kein Emulator in dieser Session).
+- [x] Meilensteine sind nicht im Darkmode verfügbar
+ → milestones.tsx (Liste) und milestone-new.tsx (neuer Meilenstein) nutzten noch die statischen (Hell-only) `colors`/`typography` statt `useTheme()` — auf das etablierte `makeStyles(colors)`-Muster umgestellt. Typecheck sauber, Lint zeigt nur vorbestehende, nicht selbst verursachte Warnungen (fehlendes `t`-Dep, fehlendes `alt`). Nicht live getestet (kein Emulator in dieser Session).
\ No newline at end of file
diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx
index b65f2bc..b7af6cc 100644
--- a/mobile/src/components/PostCard.tsx
+++ b/mobile/src/components/PostCard.tsx
@@ -68,6 +68,8 @@ type Props = {
actingPetId: string;
/** Set when this post was mixed into the feed from explore.getTrending rather than being a real followed-pet post — see FeedScreen's interleaving logic. */
isTrending?: boolean;
+ /** False once scrolled off-screen — pauses video playback instead of looping forever unseen (found.md). Defaults true for single-post screens without viewability tracking. */
+ isVisible?: boolean;
};
/**
@@ -77,7 +79,7 @@ type Props = {
* playback UI is an explicit fast-follow). Repost ("Paw-Back") posts
* delegate entirely to RepostCard before any of this renders.
*/
-export function PostCard({ post, actingPetId, isTrending }: Props) {
+export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Props) {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const router = useRouter();
@@ -159,6 +161,7 @@ export function PostCard({ post, actingPetId, isTrending }: Props) {
initialStatus={post.videoPost.status}
initialPlaybackId={post.videoPost.muxPlaybackId}
width={cardWidth}
+ isVisible={isVisible}
/>
) : (
diff --git a/mobile/src/components/VideoPlayer.tsx b/mobile/src/components/VideoPlayer.tsx
index 23cd6a3..f1f0e0e 100644
--- a/mobile/src/components/VideoPlayer.tsx
+++ b/mobile/src/components/VideoPlayer.tsx
@@ -15,6 +15,8 @@ type Props = {
initialStatus: Status;
initialPlaybackId?: string | null;
width: number;
+ /** False once the post has scrolled off-screen — pauses playback instead of looping forever unseen (found.md). Defaults true for call sites without viewability tracking (single-post screens). */
+ isVisible?: boolean;
};
/**
@@ -22,7 +24,7 @@ type Props = {
* expo-video plays natively (AVPlayer/ExoPlayer under the hood) — no
* separate player SDK needed the way web needs @mux/mux-player-react.
*/
-export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width }: Props) {
+export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width, isVisible = true }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -63,14 +65,18 @@ export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width }:
);
}
- return ;
+ return ;
}
-function ReadyVideo({ playbackId, width }: { playbackId: string; width: number }) {
+function ReadyVideo({ playbackId, width, isVisible }: { playbackId: string; width: number; isVisible: boolean }) {
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
p.loop = true;
});
+ useEffect(() => {
+ if (!isVisible) player.pause();
+ }, [isVisible, player]);
+
return (
void;
-}) {
- const { t, i18n } = useTranslation();
- const trpc = useTRPC();
- const [open, setOpen] = useState(false);
- const { data: breeds = [], isLoading } = useQuery({
- ...trpc.pets.listBreeds.queryOptions({ speciesId: speciesId ?? "", locale: i18n.language }),
- enabled: !!speciesId,
- });
-
- return (
- speciesId && setOpen((v) => !v)}
- disabled={!speciesId}
- selectedName={selectedName}
- placeholder={speciesId ? t("petForm.selectBreed") : t("petForm.selectSpeciesFirst")}
- isLoading={isLoading}
- options={breeds}
- emptyLabel={t("petForm.noBreedsFound")}
- onSelect={(o) => {
- onSelect(o);
- setOpen(false);
- }}
- />
- );
-}
-
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
diff --git a/mobile/src/components/pet/BreedPicker.tsx b/mobile/src/components/pet/BreedPicker.tsx
new file mode 100644
index 0000000..84635b5
--- /dev/null
+++ b/mobile/src/components/pet/BreedPicker.tsx
@@ -0,0 +1,44 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQuery } from "@tanstack/react-query";
+import { useTRPC } from "../../api/trpc";
+import { OptionPicker, type PickerOption } from "../OptionPicker";
+
+type Props = {
+ speciesId: string | null;
+ selectedName: string | null;
+ onSelect: (option: PickerOption) => void;
+};
+
+/**
+ * Extracted from onboarding/PetCreateForm.tsx once edit.tsx needed the same
+ * species-scoped breed dropdown (found.md: breed wasn't editable after pet
+ * creation) — kept as its own component instead of duplicating the query +
+ * OptionPicker wiring a second time.
+ */
+export function BreedPicker({ speciesId, selectedName, onSelect }: Props) {
+ const { t, i18n } = useTranslation();
+ const trpc = useTRPC();
+ const [open, setOpen] = useState(false);
+ const { data: breeds = [], isLoading } = useQuery({
+ ...trpc.pets.listBreeds.queryOptions({ speciesId: speciesId ?? "", locale: i18n.language }),
+ enabled: !!speciesId,
+ });
+
+ return (
+ speciesId && setOpen((v) => !v)}
+ disabled={!speciesId}
+ selectedName={selectedName}
+ placeholder={speciesId ? t("petForm.selectBreed") : t("petForm.selectSpeciesFirst")}
+ isLoading={isLoading}
+ options={breeds}
+ emptyLabel={t("petForm.noBreedsFound")}
+ onSelect={(o) => {
+ onSelect(o);
+ setOpen(false);
+ }}
+ />
+ );
+}
diff --git a/mobile/src/hooks/useVisiblePostIds.ts b/mobile/src/hooks/useVisiblePostIds.ts
new file mode 100644
index 0000000..f80c7ba
--- /dev/null
+++ b/mobile/src/hooks/useVisiblePostIds.ts
@@ -0,0 +1,30 @@
+import { useState } from "react";
+import type { ViewToken } from "react-native";
+
+/**
+ * found.md: videos kept looping forever after being scrolled off-screen —
+ * expo-video's player has no built-in "am I still on screen" awareness, so
+ * a post's VideoPlayer needs to be told explicitly. Attach the returned
+ * `viewabilityConfigCallbackPairs` to a feed FlatList (it must stay a stable
+ * reference per RN's own docs) and check `visibleIds.has(post.id)` per row —
+ * relies on keyExtractor returning the post id, which every FlatList that
+ * renders PostCard already does.
+ *
+ * Built on useState's lazy initializer rather than useRef: this project's
+ * lint config (react-hooks/refs) flags reading `ref.current` during render,
+ * which the FlatList prop itself would require every render.
+ */
+export function useVisiblePostIds() {
+ const [visibleIds, setVisibleIds] = useState>(new Set());
+
+ const [viewabilityConfigCallbackPairs] = useState(() => [
+ {
+ viewabilityConfig: { itemVisiblePercentThreshold: 60 },
+ onViewableItemsChanged: ({ viewableItems }: { viewableItems: ViewToken[] }) => {
+ setVisibleIds(new Set(viewableItems.map((token) => String(token.key))));
+ },
+ },
+ ]);
+
+ return { visibleIds, viewabilityConfigCallbackPairs };
+}
diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json
index 9b64215..77697af 100644
--- a/mobile/src/i18n/locales/de.json
+++ b/mobile/src/i18n/locales/de.json
@@ -61,6 +61,10 @@
"nameLabel": "Name",
"nicknameLabel": "Rufname",
"bioLabel": "Bio",
+ "breedLabel": "Rasse",
+ "birthdayLabel": "Geburtstag (optional)",
+ "adoptedAtLabel": "Adoptionstag (optional)",
+ "invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben oder leer lassen.",
"deleteConfirmTitle": "Tier wirklich löschen?",
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
"deleteConfirmMessageFallback": "Dieses Tier",
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index c4ef56b..fdf23e4 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -61,6 +61,10 @@
"nameLabel": "Name",
"nicknameLabel": "Nickname",
"bioLabel": "Bio",
+ "breedLabel": "Breed",
+ "birthdayLabel": "Birthday (optional)",
+ "adoptedAtLabel": "Adoption date (optional)",
+ "invalidDate": "Please enter a valid date as DD-MM-YYYY, or leave it empty.",
"deleteConfirmTitle": "Delete this pet?",
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
"deleteConfirmMessageFallback": "This pet",