Today/Earlier grouped rows with actor-byline structure, unread dots and tint, thumbnails, and swipe-to-read/delete actions
import * as React from "react";
import { useState } from "react";
import { View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, EmptyState, Glass, Icon, Image, RefreshableScrollView, SwipeableCard, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, useHaptic, usePressAnimation, useTheme } from "@lumioui/core";
import { Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface NotificationActor {
name: string;
avatarUrl?: string;
}
export interface NotificationItem {
id: string;
/** Social notifications: actor + verb (+ optional subject, rendered inline muted). */
actorName?: string;
actorAvatarUrl?: string;
/** Extra actors for stacked-avatar rows — "Maya and 2 others liked…". */
extraActors?: NotificationActor[];
verb?: string;
/** Quoted target — e.g. the post title that was liked. */
subject?: string;
/** System notifications: single-line title instead of actor/verb. */
title?: string;
body?: string;
/** Relative timestamp — "2m", "3h", "1d". */
time: string;
/** Group the row under "Today", "Yesterday", or "Earlier". */
section: "today" | "yesterday" | "earlier";
unread?: boolean;
/** Fallback leading icon when there's no actor avatar (e.g. "bell"). */
icon?: IconName;
/** Trailing photo for content notifications. */
thumbnailUrl?: string;
}
export interface NotificationsScreenProps {
notifications?: NotificationItem[];
onNotificationPress?: (notification: NotificationItem) => void;
/** Swipe action: mark one row read/unread. */
onToggleRead?: (notification: NotificationItem) => void;
/** Swipe action: remove one row. */
onDismiss?: (notification: NotificationItem) => void;
onMarkAllRead?: () => void;
/** Called on pull-to-refresh; resolve by updating `notifications`. May be async. */
onRefresh?: () => void | Promise<void>;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_NOTIFICATIONS: NotificationItem[] = [
{
id: "1",
actorName: "Maya Chen",
actorAvatarUrl: face(47),
verb: "liked your article",
subject: "“The quiet craft of empty states”",
time: "2m",
section: "today",
unread: true,
thumbnailUrl: "https://picsum.photos/seed/lumioui-note-1/200/200",
},
{
id: "2",
actorName: "Tom Reed",
actorAvatarUrl: face(12),
verb: "commented",
body: "Great write-up — the spacing section especially.",
time: "26m",
section: "today",
unread: true,
},
{
id: "3",
title: "Your weekly digest is ready",
body: "5 new stories in Design and Engineering",
icon: "mail",
time: "3h",
section: "today",
unread: true,
},
{
id: "4",
actorName: "Priya Nair",
actorAvatarUrl: face(32),
extraActors: [
{ name: "Diego Ruiz", avatarUrl: face(53) },
{ name: "Ava Martinez", avatarUrl: face(44) },
],
verb: "liked your photo",
time: "9h",
section: "yesterday",
thumbnailUrl: "https://picsum.photos/seed/lumioui-note-4/200/200",
},
{
id: "5",
actorName: "Sam Okafor",
actorAvatarUrl: face(59),
verb: "started following you",
time: "1d",
section: "yesterday",
},
{
id: "6",
actorName: "Lumio",
verb: "published",
subject: "“Reanimated 4 migration guide”",
time: "2d",
section: "earlier",
},
{
id: "7",
title: "Update available",
body: "Version 2.4 ships dark-mode fixes",
icon: "arrow-up",
time: "4d",
section: "earlier",
},
];
interface NotificationRowProps {
item: NotificationItem;
onPress?: (item: NotificationItem) => void;
onToggleRead?: (item: NotificationItem) => void;
onDismiss?: (item: NotificationItem) => void;
}
function NotificationRow({ item, onPress, onToggleRead, onDismiss }: NotificationRowProps) {
const haptics = useHaptic();
const { colors } = useTheme();
const press = usePressAnimation({ pressedScale: 0.98, fast: true });
const actorLine = item.actorName
? item.extraActors?.length
? item.extraActors.length === 1
? `${item.actorName} and ${item.extraActors[0]!.name}`
: `${item.actorName} and ${item.extraActors.length} others`
: item.actorName
: null;
const headline = actorLine
? `${actorLine} ${item.verb ?? ""}`.trim()
: (item.title ?? "Notification");
const a11yLabel = [headline, item.subject, item.body, `${item.time} ago`, item.unread && "unread"]
.filter(Boolean)
.join(", ");
const leading = item.extraActors?.length ? (
// Multi-actor overlap stack — bordered so faces read as separate chips.
<View className="flex-row items-center">
<Avatar
size="md"
source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
fallback={item.actorName}
alt={item.actorName}
className="border-2 border-background"
/>
{item.extraActors.slice(0, 2).map((actor) => (
<Avatar
key={actor.name}
size="md"
source={actor.avatarUrl ? { uri: actor.avatarUrl } : undefined}
fallback={actor.name}
alt={actor.name}
className="-ml-3 border-2 border-background"
/>
))}
</View>
) : item.actorName ? (
<Avatar
size="md"
source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
fallback={item.actorName}
alt={item.actorName}
/>
) : (
<View className="h-10 w-10 items-center justify-center rounded-full bg-[#F4F5F9] dark:bg-white/10">
<Icon name={item.icon ?? "bell"} size="sm" color="foreground" />
</View>
);
return (
<SwipeableCard
className="rounded-none"
rightActions={[
{
icon: item.unread ? "check" : "mail",
label: item.unread ? "Read" : "Unread",
color: colors.primary,
onPress: () => onToggleRead?.(item),
},
{
icon: "trash",
label: "Delete",
color: colors.destructive,
onPress: () => onDismiss?.(item),
},
]}
>
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.(item);
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={a11yLabel}
className={cn(
"min-h-11 w-full flex-row items-center gap-3 px-4 py-3",
item.unread ? "bg-[#EDF8C4] dark:bg-[#1E2410]" : "bg-white dark:bg-[#1A1A20]"
)}
>
{leading}
<View className="flex-1 gap-0.5">
{actorLine ? (
<Text variant="small" numberOfLines={2}>
<Text variant="small" className="font-semibold">
{actorLine}
</Text>
{item.verb ? ` ${item.verb}` : ""}
{item.subject ? (
<Text variant="small" className="text-muted-foreground">
{` ${item.subject}`}
</Text>
) : null}
</Text>
) : (
<Text variant="small" className="font-semibold" numberOfLines={2}>
{item.title}
</Text>
)}
{item.body ? (
<Text variant="muted" numberOfLines={2}>
{item.body}
</Text>
) : null}
<Text variant="muted" className="text-xs">
{item.time} ago
</Text>
</View>
{item.unread ? (
<View
className="h-2 w-2 shrink-0 rounded-full bg-[#65A30D] dark:bg-[#BEF04C]"
accessibilityElementsHidden
importantForAccessibility="no"
/>
) : null}
{item.thumbnailUrl ? (
<Image
source={{ uri: item.thumbnailUrl }}
rounded="lg"
className="h-11 w-11"
accessibilityLabel="Related content"
/>
) : null}
</AnimatedPressable>
</SwipeableCard>
);
}
NotificationRow.displayName = "NotificationRow";
function SectionHeader({ title, unread }: { title: string; unread: number }) {
return (
<View className="flex-row items-baseline justify-between px-6 pb-1 pt-6">
<Text
accessibilityRole="header"
className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
{title}
</Text>
{unread > 0 ? (
<Text className="text-xs font-semibold text-[#65A30D] dark:text-[#BEF04C]">{unread} new</Text>
) : null}
</View>
);
}
SectionHeader.displayName = "SectionHeader";
const SECTIONS: { key: NotificationItem["section"]; title: string }[] = [
{ key: "today", title: "Today" },
{ key: "yesterday", title: "Yesterday" },
{ key: "earlier", title: "Earlier" },
];
/** Notifications — day-grouped rows, unread tint + dots, overlap avatars,
* and swipe actions. */
export function NotificationsScreen({
notifications = DEFAULT_NOTIFICATIONS,
onNotificationPress,
onToggleRead,
onDismiss,
onMarkAllRead,
onRefresh,
}: NotificationsScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
// Await the callback (sync or async) so the spinner tracks real work.
await Promise.resolve(onRefresh());
} finally {
setRefreshing(false);
}
};
const groups = SECTIONS.map((s) => ({
...s,
items: notifications.filter((n) => n.section === s.key),
})).filter((g) => g.items.length > 0);
const hasUnread = notifications.some((n) => n.unread);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + title pill + mark-all-read pill. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center justify-between gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Press
onPress={() => haptics.light()}
accessibilityLabel="Go back"
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<Glass
effect="regular"
className="h-11 items-center justify-center rounded-full px-4"
>
<Press
onPress={() => {
haptics.success();
onMarkAllRead?.();
}}
disabled={!hasUnread}
accessibilityLabel="Mark all notifications as read"
accessibilityRole="button"
accessibilityState={{ disabled: !hasUnread }}
className="min-h-11 justify-center"
>
<Text className="text-sm font-semibold" style={{ opacity: hasUnread ? 1 : 0.45 }}>
Mark all read
</Text>
</Press>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Notifications
</Text>
</Glass>
</View>
</View>
<RefreshableScrollView
refreshing={refreshing}
onRefresh={handleRefresh}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{groups.map((group, gi) => {
const unread = group.items.filter((n) => n.unread).length;
return (
<View key={group.key}>
<Mount delay={40 + gi * 80}>
<SectionHeader title={group.title} unread={unread} />
</Mount>
{group.items.map((item, i) => (
<Mount
key={item.id}
delay={Math.min(80 + gi * 80 + i * ROW_STAGGER, ROW_STAGGER_CAP + gi * 80)}
>
<NotificationRow
item={item}
onPress={onNotificationPress}
onToggleRead={onToggleRead}
onDismiss={onDismiss}
/>
</Mount>
))}
</View>
);
})}
{notifications.length === 0 ? (
<EmptyState
title="You're all caught up"
description="New notifications will appear here."
icon={<Icon name="bell" size="lg" color="mutedForeground" />}
className="py-24"
/>
) : null}
{notifications.length > 0 ? (
<Text variant="muted" className="px-6 pt-6 text-center text-xs">
Notifications older than 30 days are cleared automatically
</Text>
) : null}
</RefreshableScrollView>
</SafeAreaView>
);
}
Recreate the "Notifications Screen" screen in React Native using LumioUI.
Goal: Today/Earlier grouped rows with actor-byline structure, unread dots and tint, thumbnails, and swipe-to-read/delete actions 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 core-notifications
```
Or pull just the components: `npx lumioui add avatar button card empty-state icon image pull-to-refresh 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
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×3
### `button` — Tactile pill button with press spring, ink wash and async loading.
- imported but not rendered directly — likely used through a sibling or as a utility
### `card` — Compound surface — header, title, content and footer slots.
- `<SwipeableCard>` ×1
### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×3
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `pull-to-refresh` — Physics-driven pull-to-refresh ScrollView — resisted pull, threshold snap, custom ring indicator.
- `<RefreshableScrollView>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×11
## 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/core/notifications.tsx` exporting NotificationsScreen. 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 { useState } from "react";
import { View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, EmptyState, Glass, Icon, Image, RefreshableScrollView, SwipeableCard, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, useHaptic, usePressAnimation, useTheme } from "@lumioui/core";
import { Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface NotificationActor {
name: string;
avatarUrl?: string;
}
export interface NotificationItem {
id: string;
/** Social notifications: actor + verb (+ optional subject, rendered inline muted). */
actorName?: string;
actorAvatarUrl?: string;
/** Extra actors for stacked-avatar rows — "Maya and 2 others liked…". */
extraActors?: NotificationActor[];
verb?: string;
/** Quoted target — e.g. the post title that was liked. */
subject?: string;
/** System notifications: single-line title instead of actor/verb. */
title?: string;
body?: string;
/** Relative timestamp — "2m", "3h", "1d". */
time: string;
/** Group the row under "Today", "Yesterday", or "Earlier". */
section: "today" | "yesterday" | "earlier";
unread?: boolean;
/** Fallback leading icon when there's no actor avatar (e.g. "bell"). */
icon?: IconName;
/** Trailing photo for content notifications. */
thumbnailUrl?: string;
}
export interface NotificationsScreenProps {
notifications?: NotificationItem[];
onNotificationPress?: (notification: NotificationItem) => void;
/** Swipe action: mark one row read/unread. */
onToggleRead?: (notification: NotificationItem) => void;
/** Swipe action: remove one row. */
onDismiss?: (notification: NotificationItem) => void;
onMarkAllRead?: () => void;
/** Called on pull-to-refresh; resolve by updating `notifications`. May be async. */
onRefresh?: () => void | Promise<void>;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_NOTIFICATIONS: NotificationItem[] = [
{
id: "1",
actorName: "Maya Chen",
actorAvatarUrl: face(47),
verb: "liked your article",
subject: "“The quiet craft of empty states”",
time: "2m",
section: "today",
unread: true,
thumbnailUrl: "https://picsum.photos/seed/lumioui-note-1/200/200",
},
{
id: "2",
actorName: "Tom Reed",
actorAvatarUrl: face(12),
verb: "commented",
body: "Great write-up — the spacing section especially.",
time: "26m",
section: "today",
unread: true,
},
{
id: "3",
title: "Your weekly digest is ready",
body: "5 new stories in Design and Engineering",
icon: "mail",
time: "3h",
section: "today",
unread: true,
},
{
id: "4",
actorName: "Priya Nair",
actorAvatarUrl: face(32),
extraActors: [
{ name: "Diego Ruiz", avatarUrl: face(53) },
{ name: "Ava Martinez", avatarUrl: face(44) },
],
verb: "liked your photo",
time: "9h",
section: "yesterday",
thumbnailUrl: "https://picsum.photos/seed/lumioui-note-4/200/200",
},
{
id: "5",
actorName: "Sam Okafor",
actorAvatarUrl: face(59),
verb: "started following you",
time: "1d",
section: "yesterday",
},
{
id: "6",
actorName: "Lumio",
verb: "published",
subject: "“Reanimated 4 migration guide”",
time: "2d",
section: "earlier",
},
{
id: "7",
title: "Update available",
body: "Version 2.4 ships dark-mode fixes",
icon: "arrow-up",
time: "4d",
section: "earlier",
},
];
interface NotificationRowProps {
item: NotificationItem;
onPress?: (item: NotificationItem) => void;
onToggleRead?: (item: NotificationItem) => void;
onDismiss?: (item: NotificationItem) => void;
}
function NotificationRow({ item, onPress, onToggleRead, onDismiss }: NotificationRowProps) {
const haptics = useHaptic();
const { colors } = useTheme();
const press = usePressAnimation({ pressedScale: 0.98, fast: true });
const actorLine = item.actorName
? item.extraActors?.length
? item.extraActors.length === 1
? `${item.actorName} and ${item.extraActors[0]!.name}`
: `${item.actorName} and ${item.extraActors.length} others`
: item.actorName
: null;
const headline = actorLine
? `${actorLine} ${item.verb ?? ""}`.trim()
: (item.title ?? "Notification");
const a11yLabel = [headline, item.subject, item.body, `${item.time} ago`, item.unread && "unread"]
.filter(Boolean)
.join(", ");
const leading = item.extraActors?.length ? (
// Multi-actor overlap stack — bordered so faces read as separate chips.
<View className="flex-row items-center">
<Avatar
size="md"
source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
fallback={item.actorName}
alt={item.actorName}
className="border-2 border-background"
/>
{item.extraActors.slice(0, 2).map((actor) => (
<Avatar
key={actor.name}
size="md"
source={actor.avatarUrl ? { uri: actor.avatarUrl } : undefined}
fallback={actor.name}
alt={actor.name}
className="-ml-3 border-2 border-background"
/>
))}
</View>
) : item.actorName ? (
<Avatar
size="md"
source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
fallback={item.actorName}
alt={item.actorName}
/>
) : (
<View className="h-10 w-10 items-center justify-center rounded-full bg-[#F4F5F9] dark:bg-white/10">
<Icon name={item.icon ?? "bell"} size="sm" color="foreground" />
</View>
);
return (
<SwipeableCard
className="rounded-none"
rightActions={[
{
icon: item.unread ? "check" : "mail",
label: item.unread ? "Read" : "Unread",
color: colors.primary,
onPress: () => onToggleRead?.(item),
},
{
icon: "trash",
label: "Delete",
color: colors.destructive,
onPress: () => onDismiss?.(item),
},
]}
>
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.(item);
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={a11yLabel}
className={cn(
"min-h-11 w-full flex-row items-center gap-3 px-4 py-3",
item.unread ? "bg-[#EDF8C4] dark:bg-[#1E2410]" : "bg-white dark:bg-[#1A1A20]"
)}
>
{leading}
<View className="flex-1 gap-0.5">
{actorLine ? (
<Text variant="small" numberOfLines={2}>
<Text variant="small" className="font-semibold">
{actorLine}
</Text>
{item.verb ? ` ${item.verb}` : ""}
{item.subject ? (
<Text variant="small" className="text-muted-foreground">
{` ${item.subject}`}
</Text>
) : null}
</Text>
) : (
<Text variant="small" className="font-semibold" numberOfLines={2}>
{item.title}
</Text>
)}
{item.body ? (
<Text variant="muted" numberOfLines={2}>
{item.body}
</Text>
) : null}
<Text variant="muted" className="text-xs">
{item.time} ago
</Text>
</View>
{item.unread ? (
<View
className="h-2 w-2 shrink-0 rounded-full bg-[#65A30D] dark:bg-[#BEF04C]"
accessibilityElementsHidden
importantForAccessibility="no"
/>
) : null}
{item.thumbnailUrl ? (
<Image
source={{ uri: item.thumbnailUrl }}
rounded="lg"
className="h-11 w-11"
accessibilityLabel="Related content"
/>
) : null}
</AnimatedPressable>
</SwipeableCard>
);
}
NotificationRow.displayName = "NotificationRow";
function SectionHeader({ title, unread }: { title: string; unread: number }) {
return (
<View className="flex-row items-baseline justify-between px-6 pb-1 pt-6">
<Text
accessibilityRole="header"
className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
{title}
</Text>
{unread > 0 ? (
<Text className="text-xs font-semibold text-[#65A30D] dark:text-[#BEF04C]">{unread} new</Text>
) : null}
</View>
);
}
SectionHeader.displayName = "SectionHeader";
const SECTIONS: { key: NotificationItem["section"]; title: string }[] = [
{ key: "today", title: "Today" },
{ key: "yesterday", title: "Yesterday" },
{ key: "earlier", title: "Earlier" },
];
/** Notifications — day-grouped rows, unread tint + dots, overlap avatars,
* and swipe actions. */
export function NotificationsScreen({
notifications = DEFAULT_NOTIFICATIONS,
onNotificationPress,
onToggleRead,
onDismiss,
onMarkAllRead,
onRefresh,
}: NotificationsScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
// Await the callback (sync or async) so the spinner tracks real work.
await Promise.resolve(onRefresh());
} finally {
setRefreshing(false);
}
};
const groups = SECTIONS.map((s) => ({
...s,
items: notifications.filter((n) => n.section === s.key),
})).filter((g) => g.items.length > 0);
const hasUnread = notifications.some((n) => n.unread);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + title pill + mark-all-read pill. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center justify-between gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Press
onPress={() => haptics.light()}
accessibilityLabel="Go back"
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<Glass
effect="regular"
className="h-11 items-center justify-center rounded-full px-4"
>
<Press
onPress={() => {
haptics.success();
onMarkAllRead?.();
}}
disabled={!hasUnread}
accessibilityLabel="Mark all notifications as read"
accessibilityRole="button"
accessibilityState={{ disabled: !hasUnread }}
className="min-h-11 justify-center"
>
<Text className="text-sm font-semibold" style={{ opacity: hasUnread ? 1 : 0.45 }}>
Mark all read
</Text>
</Press>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Notifications
</Text>
</Glass>
</View>
</View>
<RefreshableScrollView
refreshing={refreshing}
onRefresh={handleRefresh}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{groups.map((group, gi) => {
const unread = group.items.filter((n) => n.unread).length;
return (
<View key={group.key}>
<Mount delay={40 + gi * 80}>
<SectionHeader title={group.title} unread={unread} />
</Mount>
{group.items.map((item, i) => (
<Mount
key={item.id}
delay={Math.min(80 + gi * 80 + i * ROW_STAGGER, ROW_STAGGER_CAP + gi * 80)}
>
<NotificationRow
item={item}
onPress={onNotificationPress}
onToggleRead={onToggleRead}
onDismiss={onDismiss}
/>
</Mount>
))}
</View>
);
})}
{notifications.length === 0 ? (
<EmptyState
title="You're all caught up"
description="New notifications will appear here."
icon={<Icon name="bell" size="lg" color="mutedForeground" />}
className="py-24"
/>
) : null}
{notifications.length > 0 ? (
<Text variant="muted" className="px-6 pt-6 text-center text-xs">
Notifications older than 30 days are cleared automatically
</Text>
) : null}
</RefreshableScrollView>
</SafeAreaView>
);
}
```
Screen: https://lumioui.dev/screens/core-notifications