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:
1 parent
72f09e9f12
commit
c1067b3245
4 files changed
+91
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user