v0.1 public beta early access on GitHub
Home / Screens / media-photo-gallery
Screen template

Photo Gallery Screen

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

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add media-photo-gallery
import { PhotoGalleryScreen } from "@/screens/media/photo-gallery";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
media/photo-gallery.tsxsource · yours to edit
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";
agent prompt — paste into Claude Code, Cursor, Devin…
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
Pixel 10 · Android
9:41
Live preview — the real screen via Expo Web · open full ↗