Spent/received summary pills, category filter chips, month-grouped sections with net totals, tinted merchant glyphs, and signed amounts
import * as React from "react";
import { useMemo, useState } from "react";
import { View, FlatList, SectionList } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
withDelay,
withTiming,
} from "react-native-reanimated";
import { Chip, EmptyState, Glass, Icon, type IconName, Separator, Text } from "@lumioui/ui";
import { AnimatedPressable, ease, timing, useHaptic, usePressAnimation, useReducedMotion, type ColorTokens, } from "@lumioui/core";
import { BrandMark, HeroGradient, LumioMark, Press, brandKey } from "../shared";
export interface Txn {
id: string;
merchant: string;
category: string;
/** Positive = credit, negative = debit. */
amount: number;
date: string;
/** Section label — typically the month ("September 2026"). Rows keep
* their order inside the group; ungrouped rows land in "Other". */
group?: string;
icon?: IconName;
/** Soft-tint tile background class — category color-coding. */
tileBg?: string;
/** Icon tint token — pairs with `tileBg`. */
tint?: keyof ColorTokens;
/** Brand key for a drawn mark (e.g. "spotify"); auto-inferred from the
* merchant name when omitted — set `brand: ""` to force the icon tile. */
brand?: string;
}
export interface TransactionsScreenProps {
transactions?: Txn[];
currency?: string;
filters?: string[];
onFilter?: (category: string) => void;
onTransactionPress?: (transaction: Txn) => void;
}
const DEFAULT_FILTERS = ["All", "Food & dining", "Transport", "Shopping", "Income", "Bills & utilities"];
const DEFAULT_TRANSACTIONS: Txn[] = [
{ id: "t1", merchant: "Starbucks", category: "Food & dining", amount: -6.4, date: "Today · 9:12", group: "September 2026" },
{ id: "t2", merchant: "Paycheck — Lumio Inc.", category: "Income", amount: 3200, date: "Yesterday", group: "September 2026", icon: "briefcase", tileBg: "bg-success/15", tint: "success" },
{ id: "t3", merchant: "Uber", category: "Transport", amount: -18.6, date: "Yesterday", group: "September 2026" },
{ id: "t4", merchant: "Netflix", category: "Bills & utilities", amount: -15.49, date: "Sep 5", group: "September 2026" },
{ id: "t5", merchant: "Whole Foods", category: "Food & dining", amount: -82.14, date: "Sep 4", group: "September 2026" },
{ id: "t6", merchant: "Amazon", category: "Shopping", amount: -34.2, date: "Sep 3", group: "September 2026" },
{ id: "t7", merchant: "Refund — Target", category: "Shopping", amount: 59.99, date: "Aug 29", group: "August 2026" },
{ id: "t8", merchant: "Spotify", category: "Bills & utilities", amount: -11.99, date: "Aug 27", group: "August 2026" },
{ id: "t9", merchant: "Metro Transit", category: "Transport", amount: -45, date: "Aug 25", group: "August 2026", icon: "tram-front", tileBg: "bg-chart-2/15", tint: "chart2" },
{ id: "t10", merchant: "Paycheck — Lumio Inc.", category: "Income", amount: 3200, date: "Aug 15", group: "August 2026", icon: "briefcase", tileBg: "bg-success/15", tint: "success" },
];
function fmt(amount: number, currency: string) {
const abs = Math.abs(amount).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return `${amount < 0 ? "−" : "+"}${currency}${abs}`;
}
/** Net of a section — credits positive, debits negative. */
function netOf(data: Txn[]) {
return data.reduce((sum, t) => sum + t.amount, 0);
}
/** Staggered mount — rows rise and fade in sequence. Choreography data,
* capped so lazily-mounted rows never wait on a deep index. */
const MOUNT_STAGGER = 40;
const MOUNT_MAX_DELAY = 280;
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) * 10 }],
}));
return (
<Animated.View style={style} className={className}>
{children}
</Animated.View>
);
}
/** Transaction row — tinted squircle glyph + signed amount, dips on press. */
function TransactionRow({
tx,
currency,
onPress,
}: {
tx: Txn;
currency: string;
onPress?: (t: Txn) => void;
}) {
const haptics = useHaptic();
// Rows dip like iOS cells — fast timing keeps feedback under a scroll.
const press = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.75, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.(tx);
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={`${tx.merchant}, ${fmt(tx.amount, currency)}, ${tx.date}`}
// px-6 lives on the row itself — RNW's SectionList never applies
// contentContainerClassName padding to the DOM, so amounts ran to
// the viewport edge.
className="flex-row items-center gap-3 px-6 py-3"
>
{/* Real merchant mark when the brand is known (Cash App / Revolut
idiom); tinted category tile otherwise. `brand: ""` opts out. */}
{(() => {
const mark = tx.brand === "" ? undefined : tx.brand ?? brandKey(tx.merchant);
if (mark) return <BrandMark brand={mark} size={44} />;
return (
<View
className={`h-11 w-11 items-center justify-center rounded-2xl ${tx.tileBg ?? "bg-primary/10"}`}
>
<Icon name={tx.icon ?? "credit-card"} size="sm" color={tx.tint ?? "primary"} />
</View>
);
})()}
<View className="flex-1">
<Text variant="small" numberOfLines={1}>
{tx.merchant}
</Text>
<Text variant="muted">
{tx.category} · {tx.date}
</Text>
</View>
{/* Credits earn the success tint; debits stay neutral — a minus sign
already reads "money out" (Cash App / Apple Card idiom). */}
<Text
variant="small"
className={`tabular-nums font-semibold ${tx.amount >= 0 ? "text-success" : ""}`}
>
{fmt(tx.amount, currency)}
</Text>
</AnimatedPressable>
);
}
/** Transactions — category filter chips over month-grouped sections with
* per-section net totals, tinted merchant glyphs, and signed amounts. */
export function TransactionsScreen({
transactions = DEFAULT_TRANSACTIONS,
currency = "$",
filters = DEFAULT_FILTERS,
onFilter,
onTransactionPress,
}: TransactionsScreenProps) {
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState(filters[0] ?? "All");
const pick = (category: string) => {
setFilter(category);
// Chip fires haptics.selection() internally — don't double it.
onFilter?.(category);
};
const visible =
filter === "All" ? transactions : transactions.filter((t) => t.category === filter);
// Money in/out over the visible set — the pills reflect the active
// filter, so "Transport" shows just transport outflow.
const totals = useMemo(() => {
let spent = 0;
let received = 0;
for (const t of visible) {
if (t.amount < 0) spent += -t.amount;
else received += t.amount;
}
return { spent, received };
}, [visible]);
// Group into month sections, preserving the order rows arrive in.
const sections = useMemo(() => {
const groups = new Map<string, Txn[]>();
for (const tx of visible) {
const key = tx.group ?? "Other";
const bucket = groups.get(key);
if (bucket) bucket.push(tx);
else groups.set(key, [tx]);
}
return [...groups.entries()].map(([title, data]) => ({
title,
data,
net: netOf(data),
}));
}, [visible]);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<View className="h-11 w-11" />
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => {}}
accessibilityRole="button"
accessibilityLabel="Search transactions"
className="h-9 w-9 items-center justify-center"
>
<Icon name="search" size="sm" color="foreground" />
</Press>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Transactions
</Text>
</View>
</View>
</View>
{/* Pending line — statements distinguish settled from in-flight. */}
<View className="flex-row items-center gap-1.5 px-6 pb-1" style={{ paddingTop: insets.top + 60 }}>
<View className="h-1.5 w-1.5 rounded-full bg-warning" />
<Text variant="muted" className="text-xs">
2 pending · may take 1–2 days to settle
</Text>
</View>
{/* Statement readout — dark gradient card: net over the visible set
with spent/received columns. Raw hex is art direction (like
StoryChrome) — the card reads dark in both schemes. */}
<View className="mx-6 mb-3 mt-2 overflow-hidden rounded-2xl bg-[#1B1B26]">
<HeroGradient from="#22222E" to="#14141C" />
<View className="gap-3 p-5">
<View className="flex-row items-center justify-between">
<Text className="text-xs font-medium uppercase tracking-wider text-white/50">
{filter === "All" ? "All activity" : filter}
</Text>
<View className="flex-row items-center gap-1 rounded-full bg-white/10 px-2 py-0.5">
<Icon name="arrow-down" size="sm" color="#4ADE80" />
<Text className="text-xs font-semibold tabular-nums text-[#4ADE80]">
{currency}
{totals.received.toLocaleString("en-US", { maximumFractionDigits: 0 })}
</Text>
</View>
</View>
<View>
<Text className="text-[28px] font-bold tracking-tight text-white tabular-nums">
{currency}
{totals.spent.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</Text>
<Text className="text-xs text-white/50">spent this period</Text>
</View>
{/* In/out ratio bar — the width pair encodes the mix. */}
<View className="h-1.5 flex-row gap-1 overflow-hidden rounded-full">
<View className="rounded-full bg-[#BEF04C]" style={{ flex: Math.max(totals.spent, 1) }} />
<View className="rounded-full bg-[#4ADE80]" style={{ flex: Math.max(totals.received, 1) }} />
</View>
</View>
</View>
<FlatList
horizontal
data={filters}
keyExtractor={(f) => f}
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2 px-6 pb-3 pt-1"
className="grow-0"
renderItem={({ item }) => (
<Chip
variant="filter"
selected={item === filter}
onPress={() => pick(item)}
accessibilityLabel={`Filter by ${item}`}
className={`h-11 px-4 ${item === filter ? "border-transparent bg-[#1B1B26]" : ""}`}
textClassName={item === filter ? "text-white" : undefined}
>
{item}
</Chip>
)}
/>
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
className="flex-1"
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
renderSectionHeader={({ section }) => (
// Month label + net for the period — the at-a-glance reconciliation
// row every banking app shows per statement month.
<View className="flex-row items-baseline justify-between bg-[#F4F5F9] px-6 pb-2 pt-5 dark:bg-[#111114]">
<Text
variant="muted"
className="text-xs font-semibold uppercase tracking-wider"
accessibilityRole="header"
>
{section.title}
</Text>
<Text
variant="muted"
className="text-xs tabular-nums"
accessibilityLabel={`Net ${fmt(section.net, currency)}`}
>
{fmt(section.net, currency)}
</Text>
</View>
)}
ItemSeparatorComponent={() => <Separator className="ml-[80px]" />}
SectionSeparatorComponent={() => <View className="h-1" />}
ListEmptyComponent={
<EmptyState
// A category filter isn't a search — use the neutral variant.
title="No transactions"
description="Nothing in this category yet."
/>
}
renderItem={({ item, index }) => (
<MountIn index={index}>
<TransactionRow
tx={item}
currency={currency}
onPress={onTransactionPress}
/>
</MountIn>
)}
/>
</SafeAreaView>
);
}
Recreate the "Transactions Screen" screen in React Native using LumioUI.
Goal: Spent/received summary pills, category filter chips, month-grouped sections with net totals, tinted merchant glyphs, and signed amounts 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 finance-transactions
```
Or pull just the components: `npx lumioui add chip empty-state icon separator 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
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×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
### `separator` — Hairline rule with orientation and spacing control.
- `<Separator>` ×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/finance/transactions.tsx` exporting TransactionsScreen. 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, SectionList } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
withDelay,
withTiming,
} from "react-native-reanimated";
import { Chip, EmptyState, Glass, Icon, type IconName, Separator, Text } from "@lumioui/ui";
import { AnimatedPressable, ease, timing, useHaptic, usePressAnimation, useReducedMotion, type ColorTokens, } from "@lumioui/core";
import { BrandMark, HeroGradient, LumioMark, Press, brandKey } from "../shared";
export interface Txn {
id: string;
merchant: string;
category: string;
/** Positive = credit, negative = debit. */
amount: number;
date: string;
/** Section label — typically the month ("September 2026"). Rows keep
* their order inside the group; ungrouped rows land in "Other". */
group?: string;
icon?: IconName;
/** Soft-tint tile background class — category color-coding. */
tileBg?: string;
/** Icon tint token — pairs with `tileBg`. */
tint?: keyof ColorTokens;
/** Brand key for a drawn mark (e.g. "spotify"); auto-inferred from the
* merchant name when omitted — set `brand: ""` to force the icon tile. */
brand?: string;
}
export interface TransactionsScreenProps {
transactions?: Txn[];
currency?: string;
filters?: string[];
onFilter?: (category: string) => void;
onTransactionPress?: (transaction: Txn) => void;
}
const DEFAULT_FILTERS = ["All", "Food & dining", "Transport", "Shopping", "Income", "Bills & utilities"];
const DEFAULT_TRANSACTIONS: Txn[] = [
{ id: "t1", merchant: "Starbucks", category: "Food & dining", amount: -6.4, date: "Today · 9:12", group: "September 2026" },
{ id: "t2", merchant: "Paycheck — Lumio Inc.", category: "Income", amount: 3200, date: "Yesterday", group: "September 2026", icon: "briefcase", tileBg: "bg-success/15", tint: "success" },
{ id: "t3", merchant: "Uber", category: "Transport", amount: -18.6, date: "Yesterday", group: "September 2026" },
{ id: "t4", merchant: "Netflix", category: "Bills & utilities", amount: -15.49, date: "Sep 5", group: "September 2026" },
{ id: "t5", merchant: "Whole Foods", category: "Food & dining", amount: -82.14, date: "Sep 4", group: "September 2026" },
{ id: "t6", merchant: "Amazon", category: "Shopping", amount: -34.2, date: "Sep 3", group: "September 2026" },
{ id: "t7", merchant: "Refund — Target", category: "Shopping", amount: 59.99, date: "Aug 29", group: "August 2026" },
{ id: "t8", merchant: "Spotify", category: "Bills & utilities", amount: -11.99, date: "Aug 27", group: "August 2026" },
{ id: "t9", merchant: "Metro Transit", category: "Transport", amount: -45, date: "Aug 25", group: "August 2026", icon: "tram-front", tileBg: "bg-chart-2/15", tint: "chart2" },
{ id: "t10", merchant: "Paycheck — Lumio Inc.", category: "Income", amount: 3200, date: "Aug 15", group: "August 2026", icon: "briefcase", tileBg: "bg-success/15", tint: "success" },
];
function fmt(amount: number, currency: string) {
const abs = Math.abs(amount).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return `${amount < 0 ? "−" : "+"}${currency}${abs}`;
}
/** Net of a section — credits positive, debits negative. */
function netOf(data: Txn[]) {
return data.reduce((sum, t) => sum + t.amount, 0);
}
/** Staggered mount — rows rise and fade in sequence. Choreography data,
* capped so lazily-mounted rows never wait on a deep index. */
const MOUNT_STAGGER = 40;
const MOUNT_MAX_DELAY = 280;
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) * 10 }],
}));
return (
<Animated.View style={style} className={className}>
{children}
</Animated.View>
);
}
/** Transaction row — tinted squircle glyph + signed amount, dips on press. */
function TransactionRow({
tx,
currency,
onPress,
}: {
tx: Txn;
currency: string;
onPress?: (t: Txn) => void;
}) {
const haptics = useHaptic();
// Rows dip like iOS cells — fast timing keeps feedback under a scroll.
const press = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.75, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.(tx);
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={`${tx.merchant}, ${fmt(tx.amount, currency)}, ${tx.date}`}
// px-6 lives on the row itself — RNW's SectionList never applies
// contentContainerClassName padding to the DOM, so amounts ran to
// the viewport edge.
className="flex-row items-center gap-3 px-6 py-3"
>
{/* Real merchant mark when the brand is known (Cash App / Revolut
idiom); tinted category tile otherwise. `brand: ""` opts out. */}
{(() => {
const mark = tx.brand === "" ? undefined : tx.brand ?? brandKey(tx.merchant);
if (mark) return <BrandMark brand={mark} size={44} />;
return (
<View
className={`h-11 w-11 items-center justify-center rounded-2xl ${tx.tileBg ?? "bg-primary/10"}`}
>
<Icon name={tx.icon ?? "credit-card"} size="sm" color={tx.tint ?? "primary"} />
</View>
);
})()}
<View className="flex-1">
<Text variant="small" numberOfLines={1}>
{tx.merchant}
</Text>
<Text variant="muted">
{tx.category} · {tx.date}
</Text>
</View>
{/* Credits earn the success tint; debits stay neutral — a minus sign
already reads "money out" (Cash App / Apple Card idiom). */}
<Text
variant="small"
className={`tabular-nums font-semibold ${tx.amount >= 0 ? "text-success" : ""}`}
>
{fmt(tx.amount, currency)}
</Text>
</AnimatedPressable>
);
}
/** Transactions — category filter chips over month-grouped sections with
* per-section net totals, tinted merchant glyphs, and signed amounts. */
export function TransactionsScreen({
transactions = DEFAULT_TRANSACTIONS,
currency = "$",
filters = DEFAULT_FILTERS,
onFilter,
onTransactionPress,
}: TransactionsScreenProps) {
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState(filters[0] ?? "All");
const pick = (category: string) => {
setFilter(category);
// Chip fires haptics.selection() internally — don't double it.
onFilter?.(category);
};
const visible =
filter === "All" ? transactions : transactions.filter((t) => t.category === filter);
// Money in/out over the visible set — the pills reflect the active
// filter, so "Transport" shows just transport outflow.
const totals = useMemo(() => {
let spent = 0;
let received = 0;
for (const t of visible) {
if (t.amount < 0) spent += -t.amount;
else received += t.amount;
}
return { spent, received };
}, [visible]);
// Group into month sections, preserving the order rows arrive in.
const sections = useMemo(() => {
const groups = new Map<string, Txn[]>();
for (const tx of visible) {
const key = tx.group ?? "Other";
const bucket = groups.get(key);
if (bucket) bucket.push(tx);
else groups.set(key, [tx]);
}
return [...groups.entries()].map(([title, data]) => ({
title,
data,
net: netOf(data),
}));
}, [visible]);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<View className="h-11 w-11" />
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => {}}
accessibilityRole="button"
accessibilityLabel="Search transactions"
className="h-9 w-9 items-center justify-center"
>
<Icon name="search" size="sm" color="foreground" />
</Press>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Transactions
</Text>
</View>
</View>
</View>
{/* Pending line — statements distinguish settled from in-flight. */}
<View className="flex-row items-center gap-1.5 px-6 pb-1" style={{ paddingTop: insets.top + 60 }}>
<View className="h-1.5 w-1.5 rounded-full bg-warning" />
<Text variant="muted" className="text-xs">
2 pending · may take 1–2 days to settle
</Text>
</View>
{/* Statement readout — dark gradient card: net over the visible set
with spent/received columns. Raw hex is art direction (like
StoryChrome) — the card reads dark in both schemes. */}
<View className="mx-6 mb-3 mt-2 overflow-hidden rounded-2xl bg-[#1B1B26]">
<HeroGradient from="#22222E" to="#14141C" />
<View className="gap-3 p-5">
<View className="flex-row items-center justify-between">
<Text className="text-xs font-medium uppercase tracking-wider text-white/50">
{filter === "All" ? "All activity" : filter}
</Text>
<View className="flex-row items-center gap-1 rounded-full bg-white/10 px-2 py-0.5">
<Icon name="arrow-down" size="sm" color="#4ADE80" />
<Text className="text-xs font-semibold tabular-nums text-[#4ADE80]">
{currency}
{totals.received.toLocaleString("en-US", { maximumFractionDigits: 0 })}
</Text>
</View>
</View>
<View>
<Text className="text-[28px] font-bold tracking-tight text-white tabular-nums">
{currency}
{totals.spent.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</Text>
<Text className="text-xs text-white/50">spent this period</Text>
</View>
{/* In/out ratio bar — the width pair encodes the mix. */}
<View className="h-1.5 flex-row gap-1 overflow-hidden rounded-full">
<View className="rounded-full bg-[#BEF04C]" style={{ flex: Math.max(totals.spent, 1) }} />
<View className="rounded-full bg-[#4ADE80]" style={{ flex: Math.max(totals.received, 1) }} />
</View>
</View>
</View>
<FlatList
horizontal
data={filters}
keyExtractor={(f) => f}
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2 px-6 pb-3 pt-1"
className="grow-0"
renderItem={({ item }) => (
<Chip
variant="filter"
selected={item === filter}
onPress={() => pick(item)}
accessibilityLabel={`Filter by ${item}`}
className={`h-11 px-4 ${item === filter ? "border-transparent bg-[#1B1B26]" : ""}`}
textClassName={item === filter ? "text-white" : undefined}
>
{item}
</Chip>
)}
/>
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
className="flex-1"
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
renderSectionHeader={({ section }) => (
// Month label + net for the period — the at-a-glance reconciliation
// row every banking app shows per statement month.
<View className="flex-row items-baseline justify-between bg-[#F4F5F9] px-6 pb-2 pt-5 dark:bg-[#111114]">
<Text
variant="muted"
className="text-xs font-semibold uppercase tracking-wider"
accessibilityRole="header"
>
{section.title}
</Text>
<Text
variant="muted"
className="text-xs tabular-nums"
accessibilityLabel={`Net ${fmt(section.net, currency)}`}
>
{fmt(section.net, currency)}
</Text>
</View>
)}
ItemSeparatorComponent={() => <Separator className="ml-[80px]" />}
SectionSeparatorComponent={() => <View className="h-1" />}
ListEmptyComponent={
<EmptyState
// A category filter isn't a search — use the neutral variant.
title="No transactions"
description="Nothing in this category yet."
/>
}
renderItem={({ item, index }) => (
<MountIn index={index}>
<TransactionRow
tx={item}
currency={currency}
onPress={onTransactionPress}
/>
</MountIn>
)}
/>
</SafeAreaView>
);
}
```
Screen: https://lumioui.dev/screens/finance-transactions