Count header, album filter chips, and a day-grouped three-column photo grid with favorite badges and a multi-select session with an action bar
import * as React from "react";
import { useMemo, useState } from "react";
import { View, FlatList } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
withDelay,
withTiming,
} from "react-native-reanimated";
import { Button, Chip, EmptyState, Glass, Icon, Image, Text } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";
import { StickyBottomBar, World } from "../shared";
export interface GalleryPhoto {
id: string;
url: string;
/** Album bucket for the filter tabs — "photo" shows everywhere. */
kind?: "photo" | "screenshot" | "shared";
favorite?: boolean;
/** Day bucket for the section headers — "Today", "Yesterday", "Sep 14".
* Photos without a day fall into a trailing "Library" group. */
day?: string;
/** Optional a11y caption — "Team lunch at Sakura Ramen". */
label?: string;
}
export interface PhotoGalleryScreenProps {
photos?: GalleryPhoto[];
title?: string;
/** Filter tab labels. The first is treated as "show everything". */
filters?: string[];
onFilter?: (filter: string) => void;
onPhotoPress?: (photo: GalleryPhoto) => void;
/** "Select" affordance in the header — enters multi-select mode. */
onSelect?: () => void;
onBack?: () => void;
}
const seed = (n: string) => `https://picsum.photos/seed/lumioui-roll-${n}/400/400`;
const DEFAULT_PHOTOS: GalleryPhoto[] = [
// — Today
{ id: "p1", url: seed("t1"), day: "Today", label: "Sunrise from the ridge trail", favorite: true },
{ id: "p2", url: seed("t2"), day: "Today", label: "Coffee on the office balcony" },
{ id: "p3", url: seed("t3"), day: "Today", kind: "screenshot", label: "Screenshot of dashboard mock" },
{ id: "p4", url: seed("t4"), day: "Today", label: "Neon sign on Mission Street" },
{ id: "p5", url: seed("t5"), day: "Today", kind: "shared", label: "Maya's mockups, round two" },
// — Yesterday
{ id: "p6", url: seed("y1"), day: "Yesterday", kind: "shared", label: "Team dinner at Sakura Ramen", favorite: true },
{ id: "p7", url: seed("y2"), day: "Yesterday", label: "Trail above the fog line" },
{ id: "p8", url: seed("y3"), day: "Yesterday", label: "Diego's mural, in progress" },
{ id: "p9", url: seed("y4"), day: "Yesterday", kind: "screenshot", label: "Screenshot of flight booking" },
{ id: "p10", url: seed("y5"), day: "Yesterday", label: "Blue hour on the rooftop" },
{ id: "p11", url: seed("y6"), day: "Yesterday", kind: "shared", label: "Launch-day cake" },
// — Sep 14
{ id: "p12", url: seed("s1"), day: "Sep 14", label: "Cherry blossoms, Kyoto" },
{ id: "p13", url: seed("s2"), day: "Sep 14", label: "Fog rolling over the bridge", favorite: true },
{ id: "p14", url: seed("s3"), day: "Sep 14", label: "Morning light in the studio" },
{ id: "p15", url: seed("s4"), day: "Sep 14", kind: "screenshot", label: "Screenshot of map route" },
{ id: "p16", url: seed("s5"), day: "Sep 14", label: "Ramen counter at lunch" },
{ id: "p17", url: seed("s6"), day: "Sep 14", kind: "shared", label: "Offsite group photo" },
{ id: "p18", url: seed("s7"), day: "Sep 14", label: "Low tide at the pier" },
];
const DEFAULT_FILTERS = ["All", "Favorites", "Screenshots", "Shared"];
/** Staggered mount — thumbs pop in cell by cell. Choreography data, capped
* so cells mounting lazily on scroll never wait on a deep index. */
const MOUNT_STAGGER = 30;
const MOUNT_MAX_DELAY = 300;
const MOUNT_TIMING = { duration: timing.normal.duration, easing: Easing.bezier(...ease.out) };
/** Fade + small rise on mount, delayed by index. Reduced motion: fade only. */
function MountIn({
index,
className,
children,
}: {
index: number;
className?: string;
children: React.ReactNode;
}) {
const reduce = useReducedMotion();
const t = useSharedValue(reduce ? 1 : 0);
React.useEffect(() => {
if (reduce) {
t.value = 1;
return;
}
t.value = withDelay(
Math.min(index * MOUNT_STAGGER, MOUNT_MAX_DELAY),
withTiming(1, MOUNT_TIMING)
);
}, [index, reduce, t]);
const style = useAnimatedStyle(() => ({
opacity: t.value,
transform: [{ translateY: (1 - t.value) * 8 }],
}));
return (
<Animated.View style={style} className={className}>
{children}
</Animated.View>
);
}
/** Map a filter label onto the photo's bucket flags. */
function matchesFilter(photo: GalleryPhoto, filter: string) {
const f = filter.toLowerCase();
if (f === "favorites") return !!photo.favorite;
if (f === "screenshots") return photo.kind === "screenshot";
if (f === "shared") return photo.kind === "shared";
return true;
}
/** Grid cell — square thumb, favorite badge, dips on press. Cells own a
* third of the row via padding (a flex `gap` would push 3×33% + gaps past
* the row width), so the hairline gutters stay exact. In select mode the
* tap toggles membership and a check disc lands top-right (Photos idiom). */
function PhotoCell({
photo,
selecting,
selected,
onPress,
onToggle,
}: {
photo: GalleryPhoto;
selecting?: boolean;
selected?: boolean;
onPress?: (p: GalleryPhoto) => void;
onToggle?: (p: GalleryPhoto) => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.94, fast: true });
return (
<AnimatedPressable
onPress={() => {
if (selecting) {
haptics.selection();
onToggle?.(photo);
} else {
haptics.light();
onPress?.(photo);
}
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="imagebutton"
accessibilityLabel={photo.label ?? "Photo"}
accessibilityState={selecting ? { selected: selected ?? false } : undefined}
className="w-full p-[1.5px]"
>
<View className="aspect-square w-full">
<Image
source={{ uri: photo.url }}
rounded="none"
className={cn("h-full w-full rounded-lg", selecting && selected && "opacity-80")}
accessibilityLabel={photo.label ?? "Photo"}
/>
{photo.favorite && !selecting ? (
<Glass interactive
effect="clear"
className="absolute bottom-1 right-1 h-5 w-5 items-center justify-center rounded-full"
accessible={false}
>
<Icon name="heart" size="sm" color="#FFF" filled />
</Glass>
) : null}
{selecting ? (
<View
className={cn(
"absolute right-1.5 top-1.5 h-6 w-6 items-center justify-center rounded-full border-2",
selected ? "border-[#65A30D] bg-[#65A30D]" : "border-white bg-black/25"
)}
accessible={false}
>
{selected ? <Icon name="check" size="sm" color="#FFF" /> : null}
</View>
) : null}
</View>
</AnimatedPressable>
);
}
interface DayGroup {
title: string;
/** Index of the first photo in the group — feeds the mount stagger. */
start: number;
photos: GalleryPhoto[];
}
/** Buckets photos into contiguous day groups, preserving sort order.
* Screenshots/favorites filters just thin the groups — empty days drop. */
function groupByDay(photos: GalleryPhoto[]): DayGroup[] {
const groups: DayGroup[] = [];
photos.forEach((photo, i) => {
const title = photo.day ?? "Library";
const last = groups[groups.length - 1];
if (last && last.title === title) last.photos.push(photo);
else groups.push({ title, start: i, photos: [photo] });
});
return groups;
}
/** Photo gallery — count header, album filter tabs, and a tight 3-column
* thumbnail grid (the Photos-app idiom). */
export function PhotoGalleryScreen({
photos = DEFAULT_PHOTOS,
title = "Photos",
filters = DEFAULT_FILTERS,
onFilter,
onPhotoPress,
onSelect,
onBack,
}: PhotoGalleryScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState(filters[0] ?? "All");
// Multi-select mode — internal state like a real Photos app: the
// "Select" affordance flips the chrome into a pick session.
const [selecting, setSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const pick = (f: string) => {
setFilter(f);
// Chip fires haptics.selection() internally — don't double it.
onFilter?.(f);
};
const visible = useMemo(
() => (filter === filters[0] ? photos : photos.filter((p) => matchesFilter(p, filter))),
[filter, filters, photos]
);
const groups = useMemo(() => groupByDay(visible), [visible]);
const count = selectedIds.size;
const enterSelect = () => {
haptics.selection();
setSelecting(true);
onSelect?.();
};
const exitSelect = () => {
haptics.selection();
setSelecting(false);
setSelectedIds(new Set());
};
const togglePhoto = (p: GalleryPhoto) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(p.id)) next.delete(p.id);
else next.add(p.id);
return next;
});
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Header — back, title + live count; swaps to a pick session while
selecting (count + Cancel), exactly like Photos. */}
<View className="flex-row items-center gap-1 px-3 pb-1 pt-4">
{onBack && !selecting ? (
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={onBack}
accessibilityLabel="Go back"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Button>
) : null}
<View className="flex-1 px-3">
<Text variant="h2" numberOfLines={1} accessibilityRole="header">
{selecting ? (count === 0 ? "Select photos" : `${count} Selected`) : title}
</Text>
{!selecting ? (
<Text variant="muted" className="text-xs tabular-nums">
{visible.length} {visible.length === 1 ? "photo" : "photos"}
</Text>
) : null}
</View>
<Button
variant="ghost"
haptic="light"
onPress={selecting ? exitSelect : enterSelect}
accessibilityLabel={selecting ? "Cancel selection" : "Select photos"}
className="px-3"
>
{selecting ? "Cancel" : "Select"}
</Button>
</View>
{/* Album filter tabs — horizontal chip row, same idiom as the
transactions screen. */}
<FlatList
horizontal
data={filters}
keyExtractor={(f) => f}
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2 px-6 pb-2 pt-2"
className="grow-0"
renderItem={({ item }) => (
<Chip
variant="filter"
selected={item === filter}
onPress={() => pick(item)}
accessibilityLabel={`Filter by ${item}`}
className="h-11 px-4"
>
{item}
</Chip>
)}
/>
{/* Day-grouped 3-up grid — a FlatList of day sections; each section
renders its header then wraps cells three per row (SectionList
can't do numColumns). */}
<FlatList
data={groups}
keyExtractor={(g) => g.title}
className="flex-1 px-[1.5px]"
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<EmptyState
title="No photos"
description="Nothing in this album yet."
/>
}
ListFooterComponent={
visible.length > 0 ? (
// Sync state — Photos always ends the roll with this receipt.
<Text
variant="muted"
className="py-4 text-center text-xs"
accessibilityLiveRegion="polite"
>
{visible.length} photos · synced just now
</Text>
) : null
}
renderItem={({ item: group }) => (
<View>
<Text
variant="small"
className="px-4 pb-1.5 pt-4 font-semibold"
accessibilityRole="header"
>
{group.title}
</Text>
<View className="flex-row flex-wrap">
{group.photos.map((photo, i) => (
// w-1/3 lives on the MountIn wrapper — the pressable inside
// fills it, so each cell claims exactly a third of the row.
<MountIn key={photo.id} index={group.start + i} className="w-1/3">
<PhotoCell
photo={photo}
selecting={selecting}
selected={selectedIds.has(photo.id)}
onPress={onPhotoPress}
onToggle={togglePhoto}
/>
</MountIn>
))}
</View>
</View>
)}
/>
{/* Selection action bar — share / favorite / delete, disarmed until
at least one thumb is checked. */}
{selecting ? (
<StickyBottomBar>
<View className="flex-row items-center justify-between overflow-hidden rounded-full py-1.5 pl-5 pr-1.5">
<Glass interactive effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Text variant="muted" className="text-sm tabular-nums">
{count === 0 ? "Nothing selected" : `${count} selected`}
</Text>
<View className="flex-row items-center gap-1">
<Button
variant="ghost"
size="icon"
haptic="light"
disabled={count === 0}
accessibilityLabel="Share selected photos"
>
<Icon name="share" size="md" color={count === 0 ? "mutedForeground" : "foreground"} />
</Button>
<Button
variant="ghost"
size="icon"
haptic="light"
disabled={count === 0}
accessibilityLabel="Favorite selected photos"
>
<Icon name="heart" size="md" color={count === 0 ? "mutedForeground" : "foreground"} />
</Button>
<Button
variant="ghost"
size="icon"
haptic="medium"
disabled={count === 0}
accessibilityLabel="Delete selected photos"
>
<Icon name="trash" size="md" color={count === 0 ? "mutedForeground" : "destructive"} />
</Button>
</View>
</View>
</StickyBottomBar>
) : null}
</SafeAreaView>
</World>
);
}
PhotoGalleryScreen.displayName = "PhotoGalleryScreen";
Recreate the "Photo Gallery Screen" screen in React Native using LumioUI.
Goal: Count header, album filter chips, and a day-grouped three-column photo grid with favorite badges and a multi-select session with an action bar Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add media-photo-gallery
```
Or pull just the components: `npx lumioui add button chip empty-state glass icon image text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×5
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1
### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1
### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×6
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×5
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/media/photo-gallery.tsx` exporting PhotoGalleryScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useMemo, useState } from "react";
import { View, FlatList } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
withDelay,
withTiming,
} from "react-native-reanimated";
import { Button, Chip, EmptyState, Glass, Icon, Image, Text } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";
import { StickyBottomBar, World } from "../shared";
export interface GalleryPhoto {
id: string;
url: string;
/** Album bucket for the filter tabs — "photo" shows everywhere. */
kind?: "photo" | "screenshot" | "shared";
favorite?: boolean;
/** Day bucket for the section headers — "Today", "Yesterday", "Sep 14".
* Photos without a day fall into a trailing "Library" group. */
day?: string;
/** Optional a11y caption — "Team lunch at Sakura Ramen". */
label?: string;
}
export interface PhotoGalleryScreenProps {
photos?: GalleryPhoto[];
title?: string;
/** Filter tab labels. The first is treated as "show everything". */
filters?: string[];
onFilter?: (filter: string) => void;
onPhotoPress?: (photo: GalleryPhoto) => void;
/** "Select" affordance in the header — enters multi-select mode. */
onSelect?: () => void;
onBack?: () => void;
}
const seed = (n: string) => `https://picsum.photos/seed/lumioui-roll-${n}/400/400`;
const DEFAULT_PHOTOS: GalleryPhoto[] = [
// — Today
{ id: "p1", url: seed("t1"), day: "Today", label: "Sunrise from the ridge trail", favorite: true },
{ id: "p2", url: seed("t2"), day: "Today", label: "Coffee on the office balcony" },
{ id: "p3", url: seed("t3"), day: "Today", kind: "screenshot", label: "Screenshot of dashboard mock" },
{ id: "p4", url: seed("t4"), day: "Today", label: "Neon sign on Mission Street" },
{ id: "p5", url: seed("t5"), day: "Today", kind: "shared", label: "Maya's mockups, round two" },
// — Yesterday
{ id: "p6", url: seed("y1"), day: "Yesterday", kind: "shared", label: "Team dinner at Sakura Ramen", favorite: true },
{ id: "p7", url: seed("y2"), day: "Yesterday", label: "Trail above the fog line" },
{ id: "p8", url: seed("y3"), day: "Yesterday", label: "Diego's mural, in progress" },
{ id: "p9", url: seed("y4"), day: "Yesterday", kind: "screenshot", label: "Screenshot of flight booking" },
{ id: "p10", url: seed("y5"), day: "Yesterday", label: "Blue hour on the rooftop" },
{ id: "p11", url: seed("y6"), day: "Yesterday", kind: "shared", label: "Launch-day cake" },
// — Sep 14
{ id: "p12", url: seed("s1"), day: "Sep 14", label: "Cherry blossoms, Kyoto" },
{ id: "p13", url: seed("s2"), day: "Sep 14", label: "Fog rolling over the bridge", favorite: true },
{ id: "p14", url: seed("s3"), day: "Sep 14", label: "Morning light in the studio" },
{ id: "p15", url: seed("s4"), day: "Sep 14", kind: "screenshot", label: "Screenshot of map route" },
{ id: "p16", url: seed("s5"), day: "Sep 14", label: "Ramen counter at lunch" },
{ id: "p17", url: seed("s6"), day: "Sep 14", kind: "shared", label: "Offsite group photo" },
{ id: "p18", url: seed("s7"), day: "Sep 14", label: "Low tide at the pier" },
];
const DEFAULT_FILTERS = ["All", "Favorites", "Screenshots", "Shared"];
/** Staggered mount — thumbs pop in cell by cell. Choreography data, capped
* so cells mounting lazily on scroll never wait on a deep index. */
const MOUNT_STAGGER = 30;
const MOUNT_MAX_DELAY = 300;
const MOUNT_TIMING = { duration: timing.normal.duration, easing: Easing.bezier(...ease.out) };
/** Fade + small rise on mount, delayed by index. Reduced motion: fade only. */
function MountIn({
index,
className,
children,
}: {
index: number;
className?: string;
children: React.ReactNode;
}) {
const reduce = useReducedMotion();
const t = useSharedValue(reduce ? 1 : 0);
React.useEffect(() => {
if (reduce) {
t.value = 1;
return;
}
t.value = withDelay(
Math.min(index * MOUNT_STAGGER, MOUNT_MAX_DELAY),
withTiming(1, MOUNT_TIMING)
);
}, [index, reduce, t]);
const style = useAnimatedStyle(() => ({
opacity: t.value,
transform: [{ translateY: (1 - t.value) * 8 }],
}));
return (
<Animated.View style={style} className={className}>
{children}
</Animated.View>
);
}
/** Map a filter label onto the photo's bucket flags. */
function matchesFilter(photo: GalleryPhoto, filter: string) {
const f = filter.toLowerCase();
if (f === "favorites") return !!photo.favorite;
if (f === "screenshots") return photo.kind === "screenshot";
if (f === "shared") return photo.kind === "shared";
return true;
}
/** Grid cell — square thumb, favorite badge, dips on press. Cells own a
* third of the row via padding (a flex `gap` would push 3×33% + gaps past
* the row width), so the hairline gutters stay exact. In select mode the
* tap toggles membership and a check disc lands top-right (Photos idiom). */
function PhotoCell({
photo,
selecting,
selected,
onPress,
onToggle,
}: {
photo: GalleryPhoto;
selecting?: boolean;
selected?: boolean;
onPress?: (p: GalleryPhoto) => void;
onToggle?: (p: GalleryPhoto) => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.94, fast: true });
return (
<AnimatedPressable
onPress={() => {
if (selecting) {
haptics.selection();
onToggle?.(photo);
} else {
haptics.light();
onPress?.(photo);
}
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="imagebutton"
accessibilityLabel={photo.label ?? "Photo"}
accessibilityState={selecting ? { selected: selected ?? false } : undefined}
className="w-full p-[1.5px]"
>
<View className="aspect-square w-full">
<Image
source={{ uri: photo.url }}
rounded="none"
className={cn("h-full w-full rounded-lg", selecting && selected && "opacity-80")}
accessibilityLabel={photo.label ?? "Photo"}
/>
{photo.favorite && !selecting ? (
<Glass interactive
effect="clear"
className="absolute bottom-1 right-1 h-5 w-5 items-center justify-center rounded-full"
accessible={false}
>
<Icon name="heart" size="sm" color="#FFF" filled />
</Glass>
) : null}
{selecting ? (
<View
className={cn(
"absolute right-1.5 top-1.5 h-6 w-6 items-center justify-center rounded-full border-2",
selected ? "border-[#65A30D] bg-[#65A30D]" : "border-white bg-black/25"
)}
accessible={false}
>
{selected ? <Icon name="check" size="sm" color="#FFF" /> : null}
</View>
) : null}
</View>
</AnimatedPressable>
);
}
interface DayGroup {
title: string;
/** Index of the first photo in the group — feeds the mount stagger. */
start: number;
photos: GalleryPhoto[];
}
/** Buckets photos into contiguous day groups, preserving sort order.
* Screenshots/favorites filters just thin the groups — empty days drop. */
function groupByDay(photos: GalleryPhoto[]): DayGroup[] {
const groups: DayGroup[] = [];
photos.forEach((photo, i) => {
const title = photo.day ?? "Library";
const last = groups[groups.length - 1];
if (last && last.title === title) last.photos.push(photo);
else groups.push({ title, start: i, photos: [photo] });
});
return groups;
}
/** Photo gallery — count header, album filter tabs, and a tight 3-column
* thumbnail grid (the Photos-app idiom). */
export function PhotoGalleryScreen({
photos = DEFAULT_PHOTOS,
title = "Photos",
filters = DEFAULT_FILTERS,
onFilter,
onPhotoPress,
onSelect,
onBack,
}: PhotoGalleryScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState(filters[0] ?? "All");
// Multi-select mode — internal state like a real Photos app: the
// "Select" affordance flips the chrome into a pick session.
const [selecting, setSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const pick = (f: string) => {
setFilter(f);
// Chip fires haptics.selection() internally — don't double it.
onFilter?.(f);
};
const visible = useMemo(
() => (filter === filters[0] ? photos : photos.filter((p) => matchesFilter(p, filter))),
[filter, filters, photos]
);
const groups = useMemo(() => groupByDay(visible), [visible]);
const count = selectedIds.size;
const enterSelect = () => {
haptics.selection();
setSelecting(true);
onSelect?.();
};
const exitSelect = () => {
haptics.selection();
setSelecting(false);
setSelectedIds(new Set());
};
const togglePhoto = (p: GalleryPhoto) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(p.id)) next.delete(p.id);
else next.add(p.id);
return next;
});
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Header — back, title + live count; swaps to a pick session while
selecting (count + Cancel), exactly like Photos. */}
<View className="flex-row items-center gap-1 px-3 pb-1 pt-4">
{onBack && !selecting ? (
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={onBack}
accessibilityLabel="Go back"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Button>
) : null}
<View className="flex-1 px-3">
<Text variant="h2" numberOfLines={1} accessibilityRole="header">
{selecting ? (count === 0 ? "Select photos" : `${count} Selected`) : title}
</Text>
{!selecting ? (
<Text variant="muted" className="text-xs tabular-nums">
{visible.length} {visible.length === 1 ? "photo" : "photos"}
</Text>
) : null}
</View>
<Button
variant="ghost"
haptic="light"
onPress={selecting ? exitSelect : enterSelect}
accessibilityLabel={selecting ? "Cancel selection" : "Select photos"}
className="px-3"
>
{selecting ? "Cancel" : "Select"}
</Button>
</View>
{/* Album filter tabs — horizontal chip row, same idiom as the
transactions screen. */}
<FlatList
horizontal
data={filters}
keyExtractor={(f) => f}
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2 px-6 pb-2 pt-2"
className="grow-0"
renderItem={({ item }) => (
<Chip
variant="filter"
selected={item === filter}
onPress={() => pick(item)}
accessibilityLabel={`Filter by ${item}`}
className="h-11 px-4"
>
{item}
</Chip>
)}
/>
{/* Day-grouped 3-up grid — a FlatList of day sections; each section
renders its header then wraps cells three per row (SectionList
can't do numColumns). */}
<FlatList
data={groups}
keyExtractor={(g) => g.title}
className="flex-1 px-[1.5px]"
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<EmptyState
title="No photos"
description="Nothing in this album yet."
/>
}
ListFooterComponent={
visible.length > 0 ? (
// Sync state — Photos always ends the roll with this receipt.
<Text
variant="muted"
className="py-4 text-center text-xs"
accessibilityLiveRegion="polite"
>
{visible.length} photos · synced just now
</Text>
) : null
}
renderItem={({ item: group }) => (
<View>
<Text
variant="small"
className="px-4 pb-1.5 pt-4 font-semibold"
accessibilityRole="header"
>
{group.title}
</Text>
<View className="flex-row flex-wrap">
{group.photos.map((photo, i) => (
// w-1/3 lives on the MountIn wrapper — the pressable inside
// fills it, so each cell claims exactly a third of the row.
<MountIn key={photo.id} index={group.start + i} className="w-1/3">
<PhotoCell
photo={photo}
selecting={selecting}
selected={selectedIds.has(photo.id)}
onPress={onPhotoPress}
onToggle={togglePhoto}
/>
</MountIn>
))}
</View>
</View>
)}
/>
{/* Selection action bar — share / favorite / delete, disarmed until
at least one thumb is checked. */}
{selecting ? (
<StickyBottomBar>
<View className="flex-row items-center justify-between overflow-hidden rounded-full py-1.5 pl-5 pr-1.5">
<Glass interactive effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Text variant="muted" className="text-sm tabular-nums">
{count === 0 ? "Nothing selected" : `${count} selected`}
</Text>
<View className="flex-row items-center gap-1">
<Button
variant="ghost"
size="icon"
haptic="light"
disabled={count === 0}
accessibilityLabel="Share selected photos"
>
<Icon name="share" size="md" color={count === 0 ? "mutedForeground" : "foreground"} />
</Button>
<Button
variant="ghost"
size="icon"
haptic="light"
disabled={count === 0}
accessibilityLabel="Favorite selected photos"
>
<Icon name="heart" size="md" color={count === 0 ? "mutedForeground" : "foreground"} />
</Button>
<Button
variant="ghost"
size="icon"
haptic="medium"
disabled={count === 0}
accessibilityLabel="Delete selected photos"
>
<Icon name="trash" size="md" color={count === 0 ? "mutedForeground" : "destructive"} />
</Button>
</View>
</View>
</StickyBottomBar>
) : null}
</SafeAreaView>
</World>
);
}
PhotoGalleryScreen.displayName = "PhotoGalleryScreen";
```
Screen: https://lumioui.dev/screens/media-photo-gallery