fix(mobile): video-loop, breed/birthday edit, avatar zoom, milestone dark mode
- Videos kept looping forever after scrolling off-screen (expo-video has no visibility awareness). New useVisiblePostIds hook tracks FlatList viewability and pauses VideoPlayer once a post scrolls away, wired into both the feed and the pet-profile post list. - Pet edit screen couldn't change breed or set birthday/adoption date, even though the backend (pets.update) already supported all three — web already used them. Extracted BreedPicker out of PetCreateForm into a shared component, added it plus TT-MM-JJJJ date fields (same validation pattern as the health forms) to edit.tsx. Its BreedPicker nests a ScrollView, so edit.tsx's outer ScrollView was swapped for a FlatList to avoid the same nested-scroll gesture conflict fixed earlier for onboarding/pet.tsx. - Pet profile's own avatar was a plain, non-tappable Image. Tapping it now opens the existing /image-viewer (same pinch-to-zoom viewer post images already use) when the pet has an avatar set. - milestones.tsx and milestone-new.tsx still imported the static light-only colors/typography tokens instead of useTheme() — switched to the established makeStyles(colors) pattern.
This commit is contained in:
1 parent
f700000e87
commit
df4d5ccec2
13 files changed
+248
-94
No files matched your search
@@ -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 }) => (
|
||||
<PostCard post={item} actingPetId={activePetId!} isTrending={item.isTrending} />
|
||||
<PostCard
|
||||
post={item}
|
||||
actingPetId={activePetId!}
|
||||
isTrending={item.isTrending}
|
||||
isVisible={visibleIds.has(item.id)}
|
||||
/>
|
||||
)}
|
||||
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
||||
ListHeaderComponent={<StoryTray />}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.separatorWrap}>
|
||||
|
||||
@@ -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<CreatableMilestoneType>("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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [breedName, setBreedName] = useState<string | null>(null);
|
||||
const [birthday, setBirthday] = useState("");
|
||||
const [adoptedAt, setAdoptedAt] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
||||
<TextField value={name} onChangeText={setName} maxLength={30} />
|
||||
// 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.
|
||||
<FlatList
|
||||
data={EMPTY_DATA}
|
||||
renderItem={null}
|
||||
contentContainerStyle={styles.container}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
||||
<TextField value={name} onChangeText={setName} maxLength={30} />
|
||||
|
||||
<Text style={styles.label}>{t("editPet.nicknameLabel")}</Text>
|
||||
<TextField value={nickname} onChangeText={setNickname} maxLength={30} />
|
||||
<Text style={styles.label}>{t("editPet.nicknameLabel")}</Text>
|
||||
<TextField value={nickname} onChangeText={setNickname} maxLength={30} />
|
||||
|
||||
<Text style={styles.label}>{t("editPet.bioLabel")}</Text>
|
||||
<TextField value={bio} onChangeText={setBio} maxLength={150} multiline style={styles.multiline} />
|
||||
<Text style={styles.label}>{t("editPet.breedLabel")}</Text>
|
||||
<BreedPicker
|
||||
speciesId={pet.species.id}
|
||||
selectedName={breedName}
|
||||
onSelect={(o) => {
|
||||
setBreedId(o.id);
|
||||
setBreedName(o.name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>{t("petForm.adoptionStory")}</Text>
|
||||
<TextField
|
||||
value={adoptionStory}
|
||||
onChangeText={setAdoptionStory}
|
||||
maxLength={500}
|
||||
multiline
|
||||
style={styles.multiline}
|
||||
/>
|
||||
<Text style={styles.label}>{t("editPet.bioLabel")}</Text>
|
||||
<TextField value={bio} onChangeText={setBio} maxLength={150} multiline style={styles.multiline} />
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Text style={styles.label}>{t("petForm.adoptionStory")}</Text>
|
||||
<TextField
|
||||
value={adoptionStory}
|
||||
onChangeText={setAdoptionStory}
|
||||
maxLength={500}
|
||||
multiline
|
||||
style={styles.multiline}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={t("common.save")}
|
||||
disabled={updateMutation.isPending}
|
||||
onPress={() =>
|
||||
updateMutation.mutate({
|
||||
petId,
|
||||
name,
|
||||
nickname: nickname || null,
|
||||
bio: bio || null,
|
||||
adoptionStory: adoptionStory || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
|
||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
||||
<Trash2 color={colors.alertRed} size={18} />
|
||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
|
||||
|
||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
||||
<Trash2 color={colors.alertRed} size={18} />
|
||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_DATA: never[] = [];
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
|
||||
@@ -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 }) => <PostCard post={item} actingPetId={activePetId ?? petId} />}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
|
||||
)}
|
||||
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.postSeparatorWrap}>
|
||||
<View style={styles.postSeparatorLine} />
|
||||
@@ -75,7 +81,20 @@ export default function PetProfileScreen() {
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
{pet.avatarKey ? (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/image-viewer",
|
||||
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
)}
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
+12
-7
@@ -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
|
||||
-
|
||||
-
|
||||
→ 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 `<Image>`. Ö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).
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<PostImages images={post.images} width={cardWidth} onPressImage={openViewer} />
|
||||
|
||||
@@ -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 <ReadyVideo playbackId={playbackId} width={width} />;
|
||||
return <ReadyVideo playbackId={playbackId} width={width} isVisible={isVisible} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<VideoView
|
||||
player={player}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTRPC } from "../../api/trpc";
|
||||
import { TextField } from "../TextField";
|
||||
import { Button } from "../Button";
|
||||
import { OptionPicker, type PickerOption } from "../OptionPicker";
|
||||
import { BreedPicker } from "../pet/BreedPicker";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
@@ -137,41 +138,6 @@ function SpeciesPicker({ selectedName, onSelect }: { selectedName: string | null
|
||||
);
|
||||
}
|
||||
|
||||
function BreedPicker({
|
||||
speciesId,
|
||||
selectedName,
|
||||
onSelect,
|
||||
}: {
|
||||
speciesId: string | null;
|
||||
selectedName: string | null;
|
||||
onSelect: (o: PickerOption) => 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 (
|
||||
<OptionPicker
|
||||
open={open}
|
||||
onToggle={() => 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({
|
||||
|
||||
@@ -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 (
|
||||
<OptionPicker
|
||||
open={open}
|
||||
onToggle={() => 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Set<string>>(new Set());
|
||||
|
||||
const [viewabilityConfigCallbackPairs] = useState(() => [
|
||||
{
|
||||
viewabilityConfig: { itemVisiblePercentThreshold: 60 },
|
||||
onViewableItemsChanged: ({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
setVisibleIds(new Set(viewableItems.map((token) => String(token.key))));
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return { visibleIds, viewabilityConfigCallbackPairs };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user