v0.1 public beta early access on GitHub
Home / Screens / finance-transactions
Screen template

Transactions Screen

Spent/received summary pills, category filter chips, month-grouped sections with net totals, tinted merchant glyphs, and signed amounts

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