feat(mobile): add header wordmark and full-screen image viewer

Header now shows the PawFeed paw+text logo on the Feed tab (matching
web's Sidebar wordmark). Tapping a post image opens a full-screen
viewer as a route-based fullScreenModal, with a blurred edge-to-edge
copy of the same photo behind the sharp foreground image instead of a
flat black backdrop, for a more dynamic Photos-app-style lightbox.
This commit is contained in:
admin committed 2026-09-05 14:53:13 +02:00
1 parent 72f09e9f12
commit c1067b3245
4 files changed
+91 -15

No files matched your search

+18 -2
View File
@@ -1,7 +1,7 @@
import { View, Text, Pressable, StyleSheet } from "react-native";
import { Tabs, useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Home, Compass, Bell, Heart, MessageCircle } from "lucide-react-native";
import { Home, Compass, Bell, Heart, MessageCircle, PawPrint } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { colors, fontFamily } from "../../../src/theme/tokens";
@@ -29,7 +29,11 @@ export default function TabsLayout() {
>
<Tabs.Screen
name="index"
options={{ title: "Feed", tabBarIcon: ({ color, size }) => <Home color={color} size={size} /> }}
options={{
headerTitle: () => <Wordmark />,
tabBarLabel: "Feed",
tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
}}
/>
<Tabs.Screen
name="explore"
@@ -47,6 +51,16 @@ export default function TabsLayout() {
);
}
/** Port of web's Sidebar.tsx wordmark (PawPrint + "PawFeed") — shown on the Feed tab's header, mirroring the brand mark web shows in its nav shell. */
function Wordmark() {
return (
<View style={styles.wordmark}>
<PawPrint color={colors.marigoldOrange} size={20} fill={colors.marigoldOrange} />
<Text style={styles.wordmarkText}>PawFeed</Text>
</View>
);
}
function MessagesHeaderButton() {
const router = useRouter();
return (
@@ -79,6 +93,8 @@ function NotificationsTabIcon({ color, size }: { color: string; size: number })
const styles = StyleSheet.create({
headerButton: { marginRight: 16 },
wordmark: { flexDirection: "row", alignItems: "center", gap: 8 },
wordmarkText: { fontFamily: fontFamily.bold, fontSize: 20, color: colors.ink },
badge: {
position: "absolute",
top: -4,
+4
View File
@@ -16,6 +16,10 @@ export default function AppLayout() {
<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.Screen
name="image-viewer"
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade" }}
/>
</Stack>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { View, Pressable, Image, useWindowDimensions, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { X } from "lucide-react-native";
import { colors } from "../../src/theme/tokens";
/**
* Full-screen "tap to enlarge" viewer for a single post image, pushed as a
* route (presented as `fullScreenModal` in (app)/_layout.tsx) rather than
* React Native's <Modal>, which didn't reliably size its content full-screen
* during development. Background is a blurred, edge-to-edge copy of the
* same photo (common lightbox pattern — Photos app, Instagram stories)
* instead of a flat black scrim, so the surrounding color still reads even
* when the photo doesn't match the screen's aspect ratio.
*
* Shows only the tapped image, not a swipeable carousel between a post's
* other images — closing and tapping a different thumbnail in the feed
* carousel shows that one instead. Multi-image swipe-in-viewer is a
* fast-follow.
*/
export default function ImageViewerScreen() {
const { images, index } = useLocalSearchParams<{ images: string; index: string }>();
const router = useRouter();
const { width, height } = useWindowDimensions();
const uris: string[] = JSON.parse(images);
const uri = uris[Number(index) || 0];
return (
<Pressable style={styles.backdrop} onPress={() => router.back()}>
<Image source={{ uri }} style={StyleSheet.absoluteFill} resizeMode="cover" blurRadius={40} />
<View style={[StyleSheet.absoluteFill, styles.scrim]} />
<Image source={{ uri }} style={{ width, height }} resizeMode="contain" />
<Pressable style={styles.closeButton} onPress={() => router.back()} hitSlop={12}>
<X color={colors.pureWhite} size={26} />
</Pressable>
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.ink },
scrim: { backgroundColor: "rgba(0,0,0,0.35)" },
closeButton: { position: "absolute", top: 48, right: 20, zIndex: 1, padding: 8 },
});
+26 -13
View File
@@ -62,6 +62,11 @@ export function PostCard({ post, actingPetId }: Props) {
const router = useRouter();
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
const openViewer = (index: number) => {
const uris = post.images.map((image) => getMediaUrl(image.storageKey));
router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(uris), index: String(index) } });
};
return (
<View style={styles.card}>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
@@ -80,7 +85,7 @@ export function PostCard({ post, actingPetId }: Props) {
<Video size={32} color={colors.pureWhite} />
</View>
) : (
<PostImages images={post.images} width={cardWidth} />
<PostImages images={post.images} width={cardWidth} onPressImage={openViewer} />
)}
{post.caption ? <Text style={styles.caption}>{post.caption}</Text> : null}
@@ -100,34 +105,42 @@ export function PostCard({ post, actingPetId }: Props) {
);
}
function PostImages({ images, width }: { images: PostCardImage[]; width: number }) {
type PostImagesProps = {
images: PostCardImage[];
width: number;
onPressImage: (index: number) => void;
};
function PostImages({ images, width, onPressImage }: PostImagesProps) {
if (images.length === 0) return null;
if (images.length === 1) {
return <PostImage image={images[0]} width={width} />;
return <PostImage image={images[0]} width={width} onPress={() => onPressImage(0)} />;
}
return (
<ScrollView horizontal pagingEnabled showsHorizontalScrollIndicator={false} style={{ width }}>
{images.map((image) => (
<PostImage key={image.id} image={image} width={width} />
{images.map((image, index) => (
<PostImage key={image.id} image={image} width={width} onPress={() => onPressImage(index)} />
))}
</ScrollView>
);
}
function PostImage({ image, width }: { image: PostCardImage; width: number }) {
function PostImage({ image, width, onPress }: { image: PostCardImage; width: number; onPress: () => void }) {
const rawRatio = image.width && image.height ? image.width / image.height : 1;
const aspectRatio = Math.min(1.91, Math.max(0.75, rawRatio));
return (
<Image
source={{ uri: getMediaUrl(image.storageKey) }}
style={{ width, aspectRatio, borderRadius: radii.md }}
contentFit="cover"
placeholder={image.blurhash ? { blurhash: image.blurhash } : undefined}
transition={150}
/>
<Pressable onPress={onPress}>
<Image
source={{ uri: getMediaUrl(image.storageKey) }}
style={{ width, aspectRatio, borderRadius: radii.md }}
contentFit="cover"
placeholder={image.blurhash ? { blurhash: image.blurhash } : undefined}
transition={150}
/>
</Pressable>
);
}