fix(mobile): feed separation, adaptive launcher icon; feat: full pet profile
Fixes from user testing on a real device: - Feed: replaced an invisible spacing-only separator with an actual hairline divider between posts. - Android launcher icon showed Expo's default placeholder (a blue arrow) because the app icon and the Android adaptive-icon layers (foreground/background/monochrome) are two separate asset sets — only icon.png/favicon.png had been replaced. Added scripts/derive-adaptive- icon.js to crop the paw glyph out of icon.png (detected by its marigold color) and generate all three adaptive-icon layers plus the splash icon; this is an explicit placeholder per user's own call, not final art. New: full pet profile (app/(app)/pets/[petId]/), matching web's src/app/(app)/pets/[petId]/page.tsx: - View: identity, species, bio/adoption story, follower/following counts (tappable → list screens), the pet's own posts below. - Follow/unfollow with real initial state (follows.isFollowing) and optimistic toggle — unlike feed reactions, this endpoint actually exists, so no need to fake an initial state. - Edit screen (name/nickname/bio/adoption story via pets.update) for the owner's own pets. - Tapping a pet (feed header, Explore card, Pets-tab row) now opens this profile instead of nowhere; the Pets tab's active-pet switch is now a separate checkmark control instead of overloading the whole row tap. PostCard's post-shape prop is now structural rather than tied to feed.getFeed's output type, since posts.byPetId selects `pet` via the narrower petIdentitySelect (no isVerified/owner) — both need to satisfy the same component. Verified end-to-end on-device: feed separator visible, own-pet profile shows a working Bearbeiten button whose form is correctly prefilled (a stray edit was made to Bianca's nickname field by mistake during testing and was discarded via back-navigation without saving — never sent to the server), someone else's pet profile shows a working Follow/Unfollow toggle, followers list navigates correctly, and the home-screen launcher icon now shows the paw instead of the default Expo icon.
No files matched your search
@@ -15,7 +15,7 @@
|
||||
"android": {
|
||||
"package": "org.pawfeed.app",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#fafafa",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../../src/api/trpc";
|
||||
import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||
@@ -62,21 +63,25 @@ export default function ExploreScreen() {
|
||||
}
|
||||
|
||||
function ExplorePetCard({ pet }: { pet: ExplorePet }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{formatPetName(pet)}
|
||||
</Text>
|
||||
<Text style={styles.followers}>{pet._count.followers} Follower</Text>
|
||||
</Card>
|
||||
<Pressable style={styles.cardPress} onPress={() => router.push(`/pets/${pet.id}`)}>
|
||||
<Card style={styles.card}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{formatPetName(pet)}
|
||||
</Text>
|
||||
<Text style={styles.followers}>{pet._count.followers} Follower</Text>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||
row: { gap: 12 },
|
||||
card: { flex: 1, alignItems: "center", gap: 6, marginBottom: 12 },
|
||||
cardPress: { flex: 1 },
|
||||
card: { alignItems: "center", gap: 6, marginBottom: 12 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
|
||||
errorText: { ...typography.body, color: colors.alertRed },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 14 },
|
||||
|
||||
@@ -47,7 +47,11 @@ export default function FeedScreen() {
|
||||
keyExtractor={(post) => post.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => <PostCard post={item} actingPetId={activePetId!} />}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.separatorWrap}>
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
)}
|
||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.marigoldOrange} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={() => {
|
||||
@@ -65,7 +69,8 @@ export default function FeedScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||
separator: { height: 24 },
|
||||
separatorWrap: { paddingVertical: 16 },
|
||||
separatorLine: { height: 1, backgroundColor: colors.hairlineGray },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
|
||||
errorText: { ...typography.body, color: colors.alertRed },
|
||||
footer: { marginVertical: 16 },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { useActivePet } from "../../../src/context/ActivePetContext";
|
||||
import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||
@@ -9,13 +10,15 @@ import { colors, typography } from "../../../src/theme/tokens";
|
||||
import type { Pet } from "../../../src/api/types";
|
||||
|
||||
/**
|
||||
* List of the owner's own pets (pets.list()). Tapping a pet sets it as the
|
||||
* "active pet" used for every pet-scoped action elsewhere in the app —
|
||||
* mobile's stand-in for web's separate pet-switcher dropdown, since there's
|
||||
* no header chrome to put one in yet. Tapping through to a pet's public
|
||||
* profile is a later phase (getProfile screen not built yet).
|
||||
* List of the owner's own pets (pets.list()). Tapping a row opens that
|
||||
* pet's profile (app/(app)/pets/[petId]/index.tsx); the checkmark is a
|
||||
* separate tap target for setting the "active pet" used by every
|
||||
* pet-scoped action elsewhere in the app — these are two different actions
|
||||
* and shouldn't share one tap target (a pet you're not actively posting as
|
||||
* should still be viewable/editable).
|
||||
*/
|
||||
export default function PetsScreen() {
|
||||
const router = useRouter();
|
||||
const { pets, activePetId, setActivePetId, isLoading } = useActivePet();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -33,7 +36,12 @@ export default function PetsScreen() {
|
||||
contentContainerStyle={styles.list}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<PetRow pet={item} isActive={item.id === activePetId} onPress={() => setActivePetId(item.id)} />
|
||||
<PetRow
|
||||
pet={item}
|
||||
isActive={item.id === activePetId}
|
||||
onPress={() => router.push(`/pets/${item.id}`)}
|
||||
onSetActive={() => setActivePetId(item.id)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
@@ -44,7 +52,17 @@ export default function PetsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function PetRow({ pet, isActive, onPress }: { pet: Pet; isActive: boolean; onPress: () => void }) {
|
||||
function PetRow({
|
||||
pet,
|
||||
isActive,
|
||||
onPress,
|
||||
onSetActive,
|
||||
}: {
|
||||
pet: Pet;
|
||||
isActive: boolean;
|
||||
onPress: () => void;
|
||||
onSetActive: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.row}>
|
||||
@@ -56,7 +74,20 @@ function PetRow({ pet, isActive, onPress }: { pet: Pet; isActive: boolean; onPre
|
||||
</View>
|
||||
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||
</View>
|
||||
{isActive && <Check color={colors.marigoldOrange} size={20} />}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetActive();
|
||||
}}
|
||||
hitSlop={12}
|
||||
style={styles.activeToggle}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check color={colors.marigoldOrange} size={20} />
|
||||
) : (
|
||||
<View style={styles.activeToggleEmpty} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -71,4 +102,12 @@ const styles = StyleSheet.create({
|
||||
nameRow: { flexDirection: "row", alignItems: "center" },
|
||||
name: { ...typography.title, fontSize: 16 },
|
||||
breed: { ...typography.body, color: colors.quietGray },
|
||||
activeToggle: { padding: 4 },
|
||||
activeToggleEmpty: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
});
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { colors, fontFamily } from "../../src/theme/tokens";
|
||||
|
||||
const headerOptions = {
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: colors.pureWhite },
|
||||
headerTitleStyle: { fontFamily: fontFamily.bold },
|
||||
} as const;
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="messages/index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Nachrichten",
|
||||
headerStyle: { backgroundColor: colors.pureWhite },
|
||||
headerTitleStyle: { fontFamily: fontFamily.bold },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="messages/index" options={{ ...headerOptions, title: "Nachrichten" }} />
|
||||
<Stack.Screen name="pets/[petId]/index" options={{ ...headerOptions, title: "Profil" }} />
|
||||
<Stack.Screen name="pets/[petId]/edit" options={{ ...headerOptions, title: "Profil bearbeiten" }} />
|
||||
<Stack.Screen name="pets/[petId]/followers" options={{ ...headerOptions, title: "Follower" }} />
|
||||
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: "Folgt" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { TextField } from "../../../../src/components/TextField";
|
||||
import { Button } from "../../../../src/components/Button";
|
||||
import { colors, typography } from "../../../../src/theme/tokens";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function EditPetScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pet, isLoading } = useQuery(trpc.pets.byId.queryOptions({ petId }));
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [adoptionStory, setAdoptionStory] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pet) return;
|
||||
setName(pet.name);
|
||||
setNickname(pet.nickname ?? "");
|
||||
setBio(pet.bio ?? "");
|
||||
setAdoptionStory(pet.adoptionStory ?? "");
|
||||
}, [pet]);
|
||||
|
||||
const updateMutation = useMutation(
|
||||
trpc.pets.update.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(trpc.pets.pathFilter());
|
||||
router.back();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoading || !pet) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<TextField value={name} onChangeText={setName} maxLength={30} />
|
||||
|
||||
<Text style={styles.label}>Rufname</Text>
|
||||
<TextField value={nickname} onChangeText={setNickname} maxLength={30} />
|
||||
|
||||
<Text style={styles.label}>Bio</Text>
|
||||
<TextField value={bio} onChangeText={setBio} maxLength={150} multiline style={styles.multiline} />
|
||||
|
||||
<Text style={styles.label}>Adoptionsgeschichte</Text>
|
||||
<TextField
|
||||
value={adoptionStory}
|
||||
onChangeText={setAdoptionStory}
|
||||
maxLength={500}
|
||||
multiline
|
||||
style={styles.multiline}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title="Speichern"
|
||||
disabled={updateMutation.isPending}
|
||||
onPress={() =>
|
||||
updateMutation.mutate({
|
||||
petId,
|
||||
name,
|
||||
nickname: nickname || null,
|
||||
bio: bio || null,
|
||||
adoptionStory: adoptionStory || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Pressable } from "react-native";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { PetAvatar } from "../../../../src/components/PetAvatar";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { colors, typography } from "../../../../src/theme/tokens";
|
||||
|
||||
export default function FollowersScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading } = useQuery(trpc.follows.listFollowers.queryOptions({ petId }));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data?.items ?? []}
|
||||
keyExtractor={(item) => item.follower.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.follower.id}`)}>
|
||||
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} />
|
||||
<Text style={typography.body}>{formatPetName(item.follower)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>Noch keine Follower.</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Pressable } from "react-native";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { PetAvatar } from "../../../../src/components/PetAvatar";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { colors, typography } from "../../../../src/theme/tokens";
|
||||
|
||||
export default function FollowingScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading } = useQuery(trpc.follows.listFollowing.queryOptions({ petId }));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data?.items ?? []}
|
||||
keyExtractor={(item) => item.followee.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.followee.id}`)}>
|
||||
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} />
|
||||
<Text style={typography.body}>{formatPetName(item.followee)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>Folgt noch niemandem.</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||
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 { formatPetName } from "../../../../src/lib/format";
|
||||
import { colors, typography } from "../../../../src/theme/tokens";
|
||||
|
||||
/**
|
||||
* Port of web's src/app/(app)/pets/[petId]/page.tsx, scoped to Phase 1.5:
|
||||
* identity, bio/adoption story, follower/following counts, follow toggle,
|
||||
* edit entry point (own pet), and the pet's own posts below. No
|
||||
* ProfileTabs/milestones split, no message button yet (no chat detail
|
||||
* screen exists to send to) — those match web's ProfileActions.tsx but
|
||||
* are deferred here.
|
||||
*/
|
||||
export default function PetProfileScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data: pet, isLoading, error } = useQuery(trpc.pets.getProfile.queryOptions({ petId }));
|
||||
const { data: followers } = useQuery(trpc.follows.listFollowers.queryOptions({ petId }));
|
||||
const { data: following } = useQuery(trpc.follows.listFollowing.queryOptions({ petId }));
|
||||
const { data: postsPage } = useQuery(trpc.posts.byPetId.queryOptions({ petId }));
|
||||
|
||||
const isOwnPet = activePetId === petId;
|
||||
|
||||
const { data: isFollowing } = useQuery({
|
||||
...trpc.follows.isFollowing.queryOptions({ followerPetId: activePetId ?? "", followeePetId: petId }),
|
||||
enabled: !isOwnPet && !!activePetId,
|
||||
});
|
||||
|
||||
const [followPending, setFollowPending] = useState(false);
|
||||
const followMutation = useMutation(
|
||||
trpc.follows.create.mutationOptions({
|
||||
onMutate: () => setFollowPending(true),
|
||||
onSuccess: () => queryClient.invalidateQueries(trpc.follows.pathFilter()),
|
||||
onSettled: () => setFollowPending(false),
|
||||
})
|
||||
);
|
||||
const unfollowMutation = useMutation(
|
||||
trpc.follows.delete.mutationOptions({
|
||||
onMutate: () => setFollowPending(true),
|
||||
onSuccess: () => queryClient.invalidateQueries(trpc.follows.pathFilter()),
|
||||
onSettled: () => setFollowPending(false),
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !pet) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error?.message ?? "Tier nicht gefunden."}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const posts = postsPage?.posts ?? [];
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={(post) => post.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => <PostCard post={item} actingPetId={activePetId ?? petId} />}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.postSeparatorWrap}>
|
||||
<View style={styles.postSeparatorLine} />
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<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} />
|
||||
</View>
|
||||
<Text style={styles.species}>{pet.species.name}</Text>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
|
||||
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>Follower</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
|
||||
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>Folgt</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwnPet ? (
|
||||
<Button title="Bearbeiten" variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
|
||||
) : (
|
||||
<Button
|
||||
title={isFollowing ? "Entfolgen" : "Folgen"}
|
||||
variant={isFollowing ? "outline" : "primary"}
|
||||
disabled={followPending || !activePetId}
|
||||
onPress={() => {
|
||||
if (!activePetId) return;
|
||||
const params = { followerPetId: activePetId, followeePetId: petId };
|
||||
if (isFollowing) unfollowMutation.mutate(params);
|
||||
else followMutation.mutate(params);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
|
||||
{pet.adoptionStory ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>Adoptionsgeschichte</Text>
|
||||
<Text style={styles.section}>{pet.adoptionStory}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>Noch keine Beiträge.</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
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 },
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,102 @@
|
||||
// One-off placeholder generator: crops the paw glyph out of assets/icon.png
|
||||
// (detected by its marigold-orange color, since the glyph sits to the left
|
||||
// of the "PawFeed" wordmark) and derives Android adaptive-icon layers from
|
||||
// it. This is explicitly a stand-in — see mobile/README.md — real
|
||||
// foreground/background/monochrome art should replace these.
|
||||
const sharp = require("sharp");
|
||||
const path = require("path");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "assets");
|
||||
const CANVAS = 1024;
|
||||
const MARIGOLD = { r: 249, g: 115, b: 22 };
|
||||
const PORCH_WHITE = "#fafafa";
|
||||
|
||||
function isMarigold(r, g, b) {
|
||||
return Math.abs(r - MARIGOLD.r) < 40 && Math.abs(g - MARIGOLD.g) < 40 && Math.abs(b - MARIGOLD.b) < 40;
|
||||
}
|
||||
|
||||
async function findGlyphBounds(iconPath) {
|
||||
const { data, info } = await sharp(iconPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
let minX = info.width, minY = info.height, maxX = 0, maxY = 0;
|
||||
for (let y = 0; y < info.height; y++) {
|
||||
for (let x = 0; x < info.width; x++) {
|
||||
const i = (y * info.width + x) * info.channels;
|
||||
if (isMarigold(data[i], data[i + 1], data[i + 2])) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { left: minX, top: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// The crop still carries the icon's near-white background as opaque
|
||||
// pixels — key those out so only the orange glyph stays visible, with a
|
||||
// soft-edged alpha falloff so it doesn't look hard-cut at low resolution.
|
||||
async function makeGlyphTransparent(croppedBuffer) {
|
||||
const { data, info } = await sharp(croppedBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
for (let p = 0; p < data.length; p += info.channels) {
|
||||
const r = data[p], g = data[p + 1], b = data[p + 2];
|
||||
const distance = Math.abs(r - MARIGOLD.r) + Math.abs(g - MARIGOLD.g) + Math.abs(b - MARIGOLD.b);
|
||||
data[p + 3] = distance < 60 ? 255 : distance < 140 ? 255 - Math.round(((distance - 60) / 80) * 255) : 0;
|
||||
}
|
||||
return sharp(data, { raw: { width: info.width, height: info.height, channels: info.channels } }).png().toBuffer();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const iconPath = path.join(ASSETS, "icon.png");
|
||||
const bounds = await findGlyphBounds(iconPath);
|
||||
|
||||
const opaqueCrop = await sharp(iconPath).extract(bounds).toBuffer();
|
||||
const glyph = await makeGlyphTransparent(opaqueCrop);
|
||||
const glyphMeta = await sharp(glyph).metadata();
|
||||
|
||||
// Scale the glyph to ~45% of the canvas, centered — comfortably inside
|
||||
// Android's adaptive-icon safe zone regardless of launcher mask shape.
|
||||
const targetSize = Math.round(CANVAS * 0.45);
|
||||
const scale = targetSize / Math.max(glyphMeta.width, glyphMeta.height);
|
||||
const resizedWidth = Math.round(glyphMeta.width * scale);
|
||||
const resizedHeight = Math.round(glyphMeta.height * scale);
|
||||
const resizedGlyph = await sharp(glyph).resize(resizedWidth, resizedHeight).toBuffer();
|
||||
|
||||
const left = Math.round((CANVAS - resizedWidth) / 2);
|
||||
const top = Math.round((CANVAS - resizedHeight) / 2);
|
||||
|
||||
// Foreground: the orange glyph, centered, transparent background.
|
||||
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
|
||||
.composite([{ input: resizedGlyph, left, top }])
|
||||
.png()
|
||||
.toFile(path.join(ASSETS, "android-icon-foreground.png"));
|
||||
|
||||
// Background: solid porch-white, matching the web app's canvas color.
|
||||
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: PORCH_WHITE } })
|
||||
.png()
|
||||
.toFile(path.join(ASSETS, "android-icon-background.png"));
|
||||
|
||||
// Monochrome: same glyph silhouette, recolored solid white (Android tints
|
||||
// this layer itself for themed/Material You icons).
|
||||
const whiteGlyph = await sharp(resizedGlyph)
|
||||
.ensureAlpha()
|
||||
.composite([{ input: Buffer.from([255, 255, 255, 255]), raw: { width: 1, height: 1, channels: 4 }, tile: true, blend: "in" }])
|
||||
.toBuffer();
|
||||
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
|
||||
.composite([{ input: whiteGlyph, left, top }])
|
||||
.png()
|
||||
.toFile(path.join(ASSETS, "android-icon-monochrome.png"));
|
||||
|
||||
// Splash icon: same as foreground (transparent glyph), matches Expo's
|
||||
// splash-screen convention of a centered logo on a solid background.
|
||||
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
|
||||
.composite([{ input: resizedGlyph, left, top }])
|
||||
.png()
|
||||
.toFile(path.join(ASSETS, "splash-icon.png"));
|
||||
|
||||
console.log("Derived adaptive icon layers from", bounds);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View, Text, StyleSheet, ScrollView, useWindowDimensions } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet, ScrollView, useWindowDimensions } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Image } from "expo-image";
|
||||
import { MessageCircle, Repeat2, Video } from "lucide-react-native";
|
||||
import { PetAvatar } from "./PetAvatar";
|
||||
@@ -7,10 +8,43 @@ import { PawButton } from "./PawButton";
|
||||
import { getMediaUrl } from "../lib/media-url";
|
||||
import { formatPetName, formatRelativeTime } from "../lib/format";
|
||||
import { colors, typography, radii } from "../theme/tokens";
|
||||
import type { FeedPost } from "../api/types";
|
||||
|
||||
/**
|
||||
* Structural rather than imported from a single router's inferred output:
|
||||
* `feed.getFeed` selects `pet` via `petCardSelect` (isVerified/owner
|
||||
* present), but `posts.byPetId` selects it via the narrower
|
||||
* `petIdentitySelect` (those keys absent, not just undefined) — both need
|
||||
* to satisfy this prop type, so isVerified/owner are optional here.
|
||||
*/
|
||||
type PostCardImage = {
|
||||
id: string;
|
||||
storageKey: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
blurhash?: string | null;
|
||||
};
|
||||
|
||||
type PostCardPost = {
|
||||
id: string;
|
||||
type?: string | null;
|
||||
caption?: string | null;
|
||||
createdAt: string | Date;
|
||||
reactionCount?: number | null;
|
||||
commentCount?: number | null;
|
||||
repostCount?: number | null;
|
||||
images: PostCardImage[];
|
||||
pet: {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
avatarKey?: string | null;
|
||||
isVerified?: boolean;
|
||||
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
post: FeedPost;
|
||||
post: PostCardPost;
|
||||
/** The signed-in owner's currently active pet — the one reacting/acting. */
|
||||
actingPetId: string;
|
||||
};
|
||||
@@ -25,11 +59,12 @@ type Props = {
|
||||
*/
|
||||
export function PostCard({ post, actingPetId }: Props) {
|
||||
const { width } = useWindowDimensions();
|
||||
const router = useRouter();
|
||||
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
|
||||
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
|
||||
<View style={styles.headerText}>
|
||||
<View style={styles.nameRow}>
|
||||
@@ -38,7 +73,7 @@ export function PostCard({ post, actingPetId }: Props) {
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{post.type === "VIDEO" ? (
|
||||
<View style={[styles.videoPlaceholder, { width: cardWidth }]}>
|
||||
@@ -65,7 +100,7 @@ export function PostCard({ post, actingPetId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function PostImages({ images, width }: { images: FeedPost["images"]; width: number }) {
|
||||
function PostImages({ images, width }: { images: PostCardImage[]; width: number }) {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
if (images.length === 1) {
|
||||
@@ -81,7 +116,7 @@ function PostImages({ images, width }: { images: FeedPost["images"]; width: numb
|
||||
);
|
||||
}
|
||||
|
||||
function PostImage({ image, width }: { image: FeedPost["images"][number]; width: number }) {
|
||||
function PostImage({ image, width }: { image: PostCardImage; width: number }) {
|
||||
const rawRatio = image.width && image.height ? image.width / image.height : 1;
|
||||
const aspectRatio = Math.min(1.91, Math.max(0.75, rawRatio));
|
||||
|
||||
|
||||