Upcoming bills with pay and autopay
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, type IconName, List, ProgressRing, Switch, Text } from "@lumioui/ui";
import { useHaptic, type ColorTokens } from "@lumioui/core";
import { LumioMark, Mount, Press, SEPARATOR_INSET, StickyBottomBar } from "../shared";
export interface Bill {
id: string;
name: string;
icon: IconName;
/** "Sep 27" */
due: string;
amount: string;
autopay?: boolean;
/** Days left → drives the due badge color. */
daysLeft?: number;
/** Soft-tint tile background class. */
tileBg?: string;
/** Icon tint token — pairs with `tileBg`. */
tint?: keyof ColorTokens;
}
export interface BillPayScreenProps {
bills?: Bill[];
onPay?: (id: string) => void;
onAutopay?: (id: string, on: boolean) => void;
onBack?: () => void;
}
const DEFAULT_BILLS: Bill[] = [
{ id: "b1", name: "Electric", icon: "zap", due: "Sep 27", amount: "$84.20", daysLeft: 4, tileBg: "bg-warning/15", tint: "warning" },
{ id: "b2", name: "Internet", icon: "wifi", due: "Oct 1", amount: "$59.99", autopay: true, tileBg: "bg-primary/15", tint: "primary" },
{ id: "b3", name: "Water", icon: "volume", due: "Oct 6", amount: "$32.48", tileBg: "bg-chart-2/15", tint: "chart2" },
{ id: "b4", name: "Rent", icon: "home", due: "Oct 1", amount: "$1,850.00", autopay: true, tileBg: "bg-chart-3/15", tint: "chart3" },
];
/** Bill pay — upcoming bills grouped by urgency with pay/autopay rows. */
export function BillPayScreen({
bills = DEFAULT_BILLS,
onPay,
onAutopay,
onBack,
}: BillPayScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [autopay, setAutopay] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(bills.map((b) => [b.id, !!b.autopay]))
);
const dueSoon = bills.filter(
(b) => (b.daysLeft !== undefined && b.daysLeft <= 5) || !autopay[b.id]
);
const scheduled = bills.filter(
(b) => !((b.daysLeft !== undefined && b.daysLeft <= 5) || !autopay[b.id])
);
const nextDue = bills
.filter((b) => b.daysLeft !== undefined)
.sort((a, b) => (a.daysLeft ?? 99) - (b.daysLeft ?? 99))[0];
const nextDays = nextDue?.daysLeft ?? 0;
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">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</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">
Bills
</Text>
</View>
</View>
</View>
{/* Due-this-month hero — total + a days-left ring on the next bill. */}
<View
className="mx-4 mb-2 flex-row items-center justify-between rounded-2xl bg-white p-4 dark:bg-[#1A1A20]"
style={{ marginTop: insets.top + 60 }}
>
<Mount>
<Text variant="small" className="text-muted-foreground">
Due this month
</Text>
<Text className="text-3xl font-bold tracking-tight">$2,026.67</Text>
<Text variant="small" className="pt-0.5 text-muted-foreground">
{bills.length} bills · next is {nextDue?.name ?? "—"}
</Text>
<Text variant="small" className="pt-0.5 text-xs text-success">
{Object.values(autopay).filter(Boolean).length} on autopay
</Text>
</Mount>
<ProgressRing
value={Math.round((1 - nextDays / 30) * 100)}
size={56}
strokeWidth={6}
color="warning"
label={`Next bill in ${nextDays} days`}
>
<Text className="text-xs font-semibold">{nextDays}d</Text>
</ProgressRing>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
>
{[
{ title: "Due soon", rows: dueSoon },
{ title: "Scheduled", rows: scheduled },
]
.filter((s) => s.rows.length > 0)
.map((section) => (
<List inset key={section.title}>
<List.Section title={section.title}>
{section.rows.map((b, ii) => {
const urgent = b.daysLeft !== undefined && b.daysLeft <= 5;
return (
<React.Fragment key={b.id}>
{ii > 0 && <List.Separator className={SEPARATOR_INSET} />}
<List.Item
title={b.name}
subtitle={
urgent
? `Due ${b.due} · ${b.daysLeft} days left`
: `Due ${b.due}`
}
leading={
<View
className={`h-8 w-8 items-center justify-center rounded-2xl ${
urgent ? "bg-warning/15" : (b.tileBg ?? "bg-muted")
}`}
>
<Icon
name={b.icon}
size="sm"
color={urgent ? "warning" : (b.tint ?? "foreground")}
/>
</View>
}
trailing={
<View className="items-end gap-1">
<Text className="font-semibold">{b.amount}</Text>
{autopay[b.id] ? (
<View className="rounded-full bg-success/15 px-2 py-0.5">
<Text className="text-[10px] font-medium text-success">
Autopay
</Text>
</View>
) : undefined}
</View>
}
className="py-3"
/>
{/* Row actions — Pay now + Autopay toggle inline so the
screen works as the bill's control surface, not a
drill-down. */}
<View className="flex-row items-center gap-2 px-4 pb-3 pl-[60px]">
<Button
tone="inverse"
haptic="light"
onPress={() => {
haptics.light();
onPay?.(b.id);
}}
accessibilityLabel={`Pay ${b.name}`}
className="h-9 flex-1 bg-[#1B1B26]"
>
Pay {b.amount}
</Button>
<View className="flex-row items-center gap-1.5">
<Text className="text-xs text-muted-foreground">
Autopay
</Text>
<Switch
checked={!!autopay[b.id]}
onCheckedChange={(v) => {
haptics.selection();
setAutopay((p) => ({ ...p, [b.id]: v }));
onAutopay?.(b.id, v);
}}
accessibilityLabel={`Autopay ${b.name}`}
/>
</View>
</View>
</React.Fragment>
);
})}
</List.Section>
</List>
))}
</ScrollView>
<StickyBottomBar>
<View className="rounded-3xl bg-white p-4 shadow-sm dark:bg-[#1A1A20]">
<Button
tone="inverse"
className="w-full bg-[#1B1B26]"
haptic="medium"
onPress={() => {
haptics.medium();
bills.filter((b) => !autopay[b.id]).forEach((b) => onPay?.(b.id));
}}
accessibilityLabel="Pay all due bills"
>
{`Pay all due · $${bills
.filter((b) => !autopay[b.id])
.reduce((s, b) => s + (parseFloat(b.amount.replace(/[^0-9.]/g, "")) || 0), 0)
.toFixed(2)}`}
</Button>
<Text variant="muted" className="pt-2 text-center text-xs">
Payments post within 1–2 business days
</Text>
</View>
</StickyBottomBar>
</SafeAreaView>
);
}
BillPayScreen.displayName = "BillPayScreen";
Recreate the "Bill Pay Screen" screen in React Native using LumioUI.
Goal: Upcoming bills with pay and autopay 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-bill-pay
```
Or pull just the components: `npx lumioui add button icon list progress-ring switch 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>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×2
### `list` — Settings-style rows with separators and press feedback.
- `<List>` ×1
### `progress-ring` — Circular progress ring with animated stroke arc, center slot and indeterminate spin.
- `<ProgressRing>` ×1
### `switch` — iOS-size toggle with spring thumb and haptics.
- `<Switch>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×10
## 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/bill-pay.tsx` exporting BillPayScreen. 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 { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, type IconName, List, ProgressRing, Switch, Text } from "@lumioui/ui";
import { useHaptic, type ColorTokens } from "@lumioui/core";
import { LumioMark, Mount, Press, SEPARATOR_INSET, StickyBottomBar } from "../shared";
export interface Bill {
id: string;
name: string;
icon: IconName;
/** "Sep 27" */
due: string;
amount: string;
autopay?: boolean;
/** Days left → drives the due badge color. */
daysLeft?: number;
/** Soft-tint tile background class. */
tileBg?: string;
/** Icon tint token — pairs with `tileBg`. */
tint?: keyof ColorTokens;
}
export interface BillPayScreenProps {
bills?: Bill[];
onPay?: (id: string) => void;
onAutopay?: (id: string, on: boolean) => void;
onBack?: () => void;
}
const DEFAULT_BILLS: Bill[] = [
{ id: "b1", name: "Electric", icon: "zap", due: "Sep 27", amount: "$84.20", daysLeft: 4, tileBg: "bg-warning/15", tint: "warning" },
{ id: "b2", name: "Internet", icon: "wifi", due: "Oct 1", amount: "$59.99", autopay: true, tileBg: "bg-primary/15", tint: "primary" },
{ id: "b3", name: "Water", icon: "volume", due: "Oct 6", amount: "$32.48", tileBg: "bg-chart-2/15", tint: "chart2" },
{ id: "b4", name: "Rent", icon: "home", due: "Oct 1", amount: "$1,850.00", autopay: true, tileBg: "bg-chart-3/15", tint: "chart3" },
];
/** Bill pay — upcoming bills grouped by urgency with pay/autopay rows. */
export function BillPayScreen({
bills = DEFAULT_BILLS,
onPay,
onAutopay,
onBack,
}: BillPayScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [autopay, setAutopay] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(bills.map((b) => [b.id, !!b.autopay]))
);
const dueSoon = bills.filter(
(b) => (b.daysLeft !== undefined && b.daysLeft <= 5) || !autopay[b.id]
);
const scheduled = bills.filter(
(b) => !((b.daysLeft !== undefined && b.daysLeft <= 5) || !autopay[b.id])
);
const nextDue = bills
.filter((b) => b.daysLeft !== undefined)
.sort((a, b) => (a.daysLeft ?? 99) - (b.daysLeft ?? 99))[0];
const nextDays = nextDue?.daysLeft ?? 0;
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">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</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">
Bills
</Text>
</View>
</View>
</View>
{/* Due-this-month hero — total + a days-left ring on the next bill. */}
<View
className="mx-4 mb-2 flex-row items-center justify-between rounded-2xl bg-white p-4 dark:bg-[#1A1A20]"
style={{ marginTop: insets.top + 60 }}
>
<Mount>
<Text variant="small" className="text-muted-foreground">
Due this month
</Text>
<Text className="text-3xl font-bold tracking-tight">$2,026.67</Text>
<Text variant="small" className="pt-0.5 text-muted-foreground">
{bills.length} bills · next is {nextDue?.name ?? "—"}
</Text>
<Text variant="small" className="pt-0.5 text-xs text-success">
{Object.values(autopay).filter(Boolean).length} on autopay
</Text>
</Mount>
<ProgressRing
value={Math.round((1 - nextDays / 30) * 100)}
size={56}
strokeWidth={6}
color="warning"
label={`Next bill in ${nextDays} days`}
>
<Text className="text-xs font-semibold">{nextDays}d</Text>
</ProgressRing>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
>
{[
{ title: "Due soon", rows: dueSoon },
{ title: "Scheduled", rows: scheduled },
]
.filter((s) => s.rows.length > 0)
.map((section) => (
<List inset key={section.title}>
<List.Section title={section.title}>
{section.rows.map((b, ii) => {
const urgent = b.daysLeft !== undefined && b.daysLeft <= 5;
return (
<React.Fragment key={b.id}>
{ii > 0 && <List.Separator className={SEPARATOR_INSET} />}
<List.Item
title={b.name}
subtitle={
urgent
? `Due ${b.due} · ${b.daysLeft} days left`
: `Due ${b.due}`
}
leading={
<View
className={`h-8 w-8 items-center justify-center rounded-2xl ${
urgent ? "bg-warning/15" : (b.tileBg ?? "bg-muted")
}`}
>
<Icon
name={b.icon}
size="sm"
color={urgent ? "warning" : (b.tint ?? "foreground")}
/>
</View>
}
trailing={
<View className="items-end gap-1">
<Text className="font-semibold">{b.amount}</Text>
{autopay[b.id] ? (
<View className="rounded-full bg-success/15 px-2 py-0.5">
<Text className="text-[10px] font-medium text-success">
Autopay
</Text>
</View>
) : undefined}
</View>
}
className="py-3"
/>
{/* Row actions — Pay now + Autopay toggle inline so the
screen works as the bill's control surface, not a
drill-down. */}
<View className="flex-row items-center gap-2 px-4 pb-3 pl-[60px]">
<Button
tone="inverse"
haptic="light"
onPress={() => {
haptics.light();
onPay?.(b.id);
}}
accessibilityLabel={`Pay ${b.name}`}
className="h-9 flex-1 bg-[#1B1B26]"
>
Pay {b.amount}
</Button>
<View className="flex-row items-center gap-1.5">
<Text className="text-xs text-muted-foreground">
Autopay
</Text>
<Switch
checked={!!autopay[b.id]}
onCheckedChange={(v) => {
haptics.selection();
setAutopay((p) => ({ ...p, [b.id]: v }));
onAutopay?.(b.id, v);
}}
accessibilityLabel={`Autopay ${b.name}`}
/>
</View>
</View>
</React.Fragment>
);
})}
</List.Section>
</List>
))}
</ScrollView>
<StickyBottomBar>
<View className="rounded-3xl bg-white p-4 shadow-sm dark:bg-[#1A1A20]">
<Button
tone="inverse"
className="w-full bg-[#1B1B26]"
haptic="medium"
onPress={() => {
haptics.medium();
bills.filter((b) => !autopay[b.id]).forEach((b) => onPay?.(b.id));
}}
accessibilityLabel="Pay all due bills"
>
{`Pay all due · $${bills
.filter((b) => !autopay[b.id])
.reduce((s, b) => s + (parseFloat(b.amount.replace(/[^0-9.]/g, "")) || 0), 0)
.toFixed(2)}`}
</Button>
<Text variant="muted" className="pt-2 text-center text-xs">
Payments post within 1–2 business days
</Text>
</View>
</StickyBottomBar>
</SafeAreaView>
);
}
BillPayScreen.displayName = "BillPayScreen";
```
Screen: https://lumioui.dev/screens/finance-bill-pay