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.
This commit is contained in:
admin committed 2026-09-05 13:47:20 +02:00
1 parent c35d4cda46
commit a737a17976
15 files changed
+583 -36

No files matched your search

+1 -1
View File
@@ -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"
+14 -9
View File
@@ -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 },
+7 -2
View File
@@ -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 },
+47 -8
View File
@@ -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,
},
});
+11 -9
View File
@@ -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>
);
}
+100
View File
@@ -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 },
});
+159
View File
@@ -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 },
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+102
View File
@@ -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);
});
+42 -7
View File
@@ -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));