diff --git a/mobile/app.json b/mobile/app.json
index d4cce29..20a878e 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -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"
diff --git a/mobile/app/(app)/(tabs)/explore.tsx b/mobile/app/(app)/(tabs)/explore.tsx
index 6b8fcf1..c876567 100644
--- a/mobile/app/(app)/(tabs)/explore.tsx
+++ b/mobile/app/(app)/(tabs)/explore.tsx
@@ -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 (
-
-
-
- {formatPetName(pet)}
-
- {pet._count.followers} Follower
-
+ router.push(`/pets/${pet.id}`)}>
+
+
+
+ {formatPetName(pet)}
+
+ {pet._count.followers} Follower
+
+
);
}
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 },
diff --git a/mobile/app/(app)/(tabs)/index.tsx b/mobile/app/(app)/(tabs)/index.tsx
index d85d0ec..127d308 100644
--- a/mobile/app/(app)/(tabs)/index.tsx
+++ b/mobile/app/(app)/(tabs)/index.tsx
@@ -47,7 +47,11 @@ export default function FeedScreen() {
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => }
- ItemSeparatorComponent={() => }
+ ItemSeparatorComponent={() => (
+
+
+
+ )}
refreshControl={}
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 },
diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx
index 89e05b1..e967b97 100644
--- a/mobile/app/(app)/(tabs)/pets.tsx
+++ b/mobile/app/(app)/(tabs)/pets.tsx
@@ -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={() => }
renderItem={({ item }) => (
- setActivePetId(item.id)} />
+ router.push(`/pets/${item.id}`)}
+ onSetActive={() => setActivePetId(item.id)}
+ />
)}
ListEmptyComponent={
@@ -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 (
@@ -56,7 +74,20 @@ function PetRow({ pet, isActive, onPress }: { pet: Pet; isActive: boolean; onPre
{pet.breed?.name ?? pet.species.name}
- {isActive && }
+ {
+ e.stopPropagation();
+ onSetActive();
+ }}
+ hitSlop={12}
+ style={styles.activeToggle}
+ >
+ {isActive ? (
+
+ ) : (
+
+ )}
+
);
@@ -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,
+ },
});
diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx
index be4b37c..97ee661 100644
--- a/mobile/app/(app)/_layout.tsx
+++ b/mobile/app/(app)/_layout.tsx
@@ -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 (
-
+
+
+
+
+
);
}
diff --git a/mobile/app/(app)/pets/[petId]/edit.tsx b/mobile/app/(app)/pets/[petId]/edit.tsx
new file mode 100644
index 0000000..aab5c5f
--- /dev/null
+++ b/mobile/app/(app)/pets/[petId]/edit.tsx
@@ -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(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 (
+
+
+
+ );
+ }
+
+ return (
+
+ Name
+
+
+ Rufname
+
+
+ Bio
+
+
+ Adoptionsgeschichte
+
+
+ {error ? {error} : null}
+
+
+ );
+}
+
+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 },
+});
diff --git a/mobile/app/(app)/pets/[petId]/followers.tsx b/mobile/app/(app)/pets/[petId]/followers.tsx
new file mode 100644
index 0000000..ec06cfa
--- /dev/null
+++ b/mobile/app/(app)/pets/[petId]/followers.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+ item.follower.id}
+ contentContainerStyle={styles.list}
+ ItemSeparatorComponent={() => }
+ renderItem={({ item }) => (
+ router.push(`/pets/${item.follower.id}`)}>
+
+ {formatPetName(item.follower)}
+
+ )}
+ ListEmptyComponent={
+
+ Noch keine Follower.
+
+ }
+ />
+ );
+}
+
+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 },
+});
diff --git a/mobile/app/(app)/pets/[petId]/following.tsx b/mobile/app/(app)/pets/[petId]/following.tsx
new file mode 100644
index 0000000..bf05f95
--- /dev/null
+++ b/mobile/app/(app)/pets/[petId]/following.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+ item.followee.id}
+ contentContainerStyle={styles.list}
+ ItemSeparatorComponent={() => }
+ renderItem={({ item }) => (
+ router.push(`/pets/${item.followee.id}`)}>
+
+ {formatPetName(item.followee)}
+
+ )}
+ ListEmptyComponent={
+
+ Folgt noch niemandem.
+
+ }
+ />
+ );
+}
+
+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 },
+});
diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx
new file mode 100644
index 0000000..6d5ded2
--- /dev/null
+++ b/mobile/app/(app)/pets/[petId]/index.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (error || !pet) {
+ return (
+
+ {error?.message ?? "Tier nicht gefunden."}
+
+ );
+ }
+
+ const posts = postsPage?.posts ?? [];
+
+ return (
+ post.id}
+ contentContainerStyle={styles.list}
+ renderItem={({ item }) => }
+ ItemSeparatorComponent={() => (
+
+
+
+ )}
+ ListHeaderComponent={
+
+
+
+ {formatPetName(pet)}
+
+
+ {pet.species.name}
+
+
+ router.push(`/pets/${petId}/followers`)}>
+ {followers?.items.length ?? 0}
+ Follower
+
+ router.push(`/pets/${petId}/following`)}>
+ {following?.items.length ?? 0}
+ Folgt
+
+
+
+ {isOwnPet ? (
+
+ }
+ ListEmptyComponent={
+
+ Noch keine Beiträge.
+
+ }
+ />
+ );
+}
+
+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 },
+});
diff --git a/mobile/assets/android-icon-background.png b/mobile/assets/android-icon-background.png
index 5ffefc5..b6b92d1 100644
Binary files a/mobile/assets/android-icon-background.png and b/mobile/assets/android-icon-background.png differ
diff --git a/mobile/assets/android-icon-foreground.png b/mobile/assets/android-icon-foreground.png
index 3a9e501..1cc8acc 100644
Binary files a/mobile/assets/android-icon-foreground.png and b/mobile/assets/android-icon-foreground.png differ
diff --git a/mobile/assets/android-icon-monochrome.png b/mobile/assets/android-icon-monochrome.png
index 77484eb..54d9b56 100644
Binary files a/mobile/assets/android-icon-monochrome.png and b/mobile/assets/android-icon-monochrome.png differ
diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png
index 03d6f6b..1cc8acc 100644
Binary files a/mobile/assets/splash-icon.png and b/mobile/assets/splash-icon.png differ
diff --git a/mobile/scripts/derive-adaptive-icon.js b/mobile/scripts/derive-adaptive-icon.js
new file mode 100644
index 0000000..6559052
--- /dev/null
+++ b/mobile/scripts/derive-adaptive-icon.js
@@ -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);
+});
diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx
index 8b8b3ce..8e25d5d 100644
--- a/mobile/src/components/PostCard.tsx
+++ b/mobile/src/components/PostCard.tsx
@@ -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 (
-
+ router.push(`/pets/${post.pet.id}`)}>
@@ -38,7 +73,7 @@ export function PostCard({ post, actingPetId }: Props) {
{formatRelativeTime(post.createdAt)}
-
+
{post.type === "VIDEO" ? (
@@ -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));