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")} + -