@obisoft/ui 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +45 -0
  2. package/package.json +52 -0
  3. package/src/components/AppHeader.tsx +67 -0
  4. package/src/components/BottomSheet.tsx +110 -0
  5. package/src/components/Button.tsx +117 -0
  6. package/src/components/Card.tsx +46 -0
  7. package/src/components/CenteredState.tsx +35 -0
  8. package/src/components/Chip.tsx +47 -0
  9. package/src/components/ChoiceField.tsx +44 -0
  10. package/src/components/DateRangeFilter.tsx +226 -0
  11. package/src/components/EmptyState.tsx +32 -0
  12. package/src/components/ErrorState.tsx +62 -0
  13. package/src/components/Fab.tsx +36 -0
  14. package/src/components/FormRow.tsx +39 -0
  15. package/src/components/FormWorkspaceShell.tsx +126 -0
  16. package/src/components/GlassTabBar.tsx +39 -0
  17. package/src/components/Icon.tsx +259 -0
  18. package/src/components/IconButton.tsx +73 -0
  19. package/src/components/ListFilterBar.tsx +134 -0
  20. package/src/components/ListRow.tsx +198 -0
  21. package/src/components/ListWorkspace.tsx +76 -0
  22. package/src/components/LoadingState.tsx +37 -0
  23. package/src/components/MetricCard.tsx +74 -0
  24. package/src/components/MetricsGrid.tsx +25 -0
  25. package/src/components/MiniBarChart.tsx +42 -0
  26. package/src/components/NavigationDrawer.tsx +243 -0
  27. package/src/components/QueryState.tsx +82 -0
  28. package/src/components/QueryWorkspace.tsx +74 -0
  29. package/src/components/ScanInputBar.tsx +69 -0
  30. package/src/components/ScannerShell.tsx +76 -0
  31. package/src/components/Screen.tsx +44 -0
  32. package/src/components/SearchArea.tsx +34 -0
  33. package/src/components/SegmentedControl.tsx +64 -0
  34. package/src/components/Skeleton.tsx +42 -0
  35. package/src/components/StatusBadge.tsx +23 -0
  36. package/src/components/SuccessState.tsx +41 -0
  37. package/src/components/TextField.tsx +47 -0
  38. package/src/components/ToggleRow.tsx +35 -0
  39. package/src/components/record-detail/RecordDetail.tsx +60 -0
  40. package/src/components/record-detail/RecordDetailActions.tsx +88 -0
  41. package/src/components/record-detail/RecordDetailField.tsx +43 -0
  42. package/src/components/record-detail/RecordDetailHero.tsx +46 -0
  43. package/src/components/record-detail/RecordDetailLine.tsx +36 -0
  44. package/src/components/record-detail/RecordDetailSection.tsx +36 -0
  45. package/src/components/record-detail/index.ts +7 -0
  46. package/src/components/record-detail/status.ts +16 -0
  47. package/src/gallery/catalog.ts +31 -0
  48. package/src/glass/AmbientBackground.tsx +29 -0
  49. package/src/glass/GlassSurface.tsx +107 -0
  50. package/src/index.ts +66 -0
  51. package/src/layout/Box.tsx +62 -0
  52. package/src/layout/Divider.tsx +8 -0
  53. package/src/layout/Row.tsx +7 -0
  54. package/src/layout/Stack.tsx +7 -0
  55. package/src/provider/ObiUIProvider.tsx +45 -0
  56. package/src/styles/useObiStyles.ts +288 -0
  57. package/src/theme/effects.ts +219 -0
  58. package/src/theme/index.ts +3 -0
  59. package/src/theme/theme.ts +53 -0
  60. package/src/theme/tokens.ts +133 -0
  61. package/src/typography/ObiText.tsx +52 -0
@@ -0,0 +1,259 @@
1
+ import React from 'react';
2
+ import {
3
+ ArrowLeft,
4
+ ArrowRight,
5
+ ArrowUpRight,
6
+ Ban,
7
+ Banknote,
8
+ Bell,
9
+ Bluetooth,
10
+ Bookmark,
11
+ BookText,
12
+ Calculator,
13
+ CalendarDays,
14
+ Car,
15
+ ChartBar,
16
+ Check,
17
+ ChevronDown,
18
+ ChevronRight,
19
+ ChevronUp,
20
+ CircleCheckBig,
21
+ ClipboardList,
22
+ Clock,
23
+ Contact,
24
+ CreditCard,
25
+ Dot,
26
+ Dumbbell,
27
+ Eye,
28
+ Factory,
29
+ Files,
30
+ FilePenLine,
31
+ FileText,
32
+ FolderKanban,
33
+ Gift,
34
+ HandCoins,
35
+ House,
36
+ KeyRound,
37
+ Landmark,
38
+ LogOut,
39
+ Mail,
40
+ Map,
41
+ MapPin,
42
+ Menu,
43
+ Minus,
44
+ MessageCircle,
45
+ Moon,
46
+ Package,
47
+ Pencil,
48
+ Pill,
49
+ Pin,
50
+ Plus,
51
+ Power,
52
+ Printer,
53
+ Receipt,
54
+ RefreshCw,
55
+ Scale,
56
+ Search,
57
+ ScanLine,
58
+ Send,
59
+ Settings,
60
+ Share2,
61
+ Shield,
62
+ Shirt,
63
+ ShoppingCart,
64
+ Sparkle,
65
+ Sparkles,
66
+ Star,
67
+ Sun,
68
+ Tag,
69
+ TrendingUp,
70
+ Trash2,
71
+ Truck,
72
+ UtensilsCrossed,
73
+ User,
74
+ Users,
75
+ Undo2,
76
+ Upload,
77
+ Wallet,
78
+ Wrench,
79
+ X,
80
+ Zap,
81
+ type LucideIcon,
82
+ } from 'lucide-react-native';
83
+
84
+ export type IconName =
85
+ | 'document'
86
+ | 'share'
87
+ | 'print'
88
+ | 'mail'
89
+ | 'bluetooth'
90
+ | 'collect'
91
+ | 'invoice'
92
+ | 'delivery'
93
+ | 'check'
94
+ | 'close'
95
+ | 'send'
96
+ | 'power'
97
+ | 'home'
98
+ | 'menu'
99
+ | 'arrow-left'
100
+ | 'arrow-right'
101
+ | 'chevron-down'
102
+ | 'chevron-right'
103
+ | 'chevron-up'
104
+ | 'dot'
105
+ | 'sun'
106
+ | 'moon'
107
+ | 'bell'
108
+ | 'log-out'
109
+ | 'settings'
110
+ | 'search'
111
+ | 'users'
112
+ | 'file-text'
113
+ | 'shield'
114
+ | 'trending-up'
115
+ | 'banknote'
116
+ | 'bar-chart'
117
+ | 'wallet'
118
+ | 'files'
119
+ | 'undo'
120
+ | 'arrow-up-right'
121
+ | 'upload'
122
+ | 'clipboard-list'
123
+ | 'truck'
124
+ | 'package'
125
+ | 'check-circle'
126
+ | 'refresh'
127
+ | 'shopping-cart'
128
+ | 'tag'
129
+ | 'bookmark'
130
+ | 'contact'
131
+ | 'map-pin'
132
+ | 'scale'
133
+ | 'calendar'
134
+ | 'factory'
135
+ | 'history'
136
+ | 'credit-card'
137
+ | 'book'
138
+ | 'calculator'
139
+ | 'map'
140
+ | 'star'
141
+ | 'message-circle'
142
+ | 'user'
143
+ | 'key'
144
+ | 'file-edit'
145
+ | 'pencil'
146
+ | 'ban'
147
+ | 'landmark'
148
+ | 'plus'
149
+ | 'minus'
150
+ | 'trash-2'
151
+ | 'scan-line'
152
+ | 'eye'
153
+ | 'folder-kanban'
154
+ | 'utensils'
155
+ | 'zap'
156
+ | 'shirt'
157
+ | 'wrench'
158
+ | 'pill'
159
+ | 'dumbbell'
160
+ | 'car'
161
+ | 'sparkles'
162
+ | 'sparkle'
163
+ | 'gift'
164
+ | 'pin';
165
+
166
+ const ICONS: Record<IconName, LucideIcon> = {
167
+ document: FileText,
168
+ share: Share2,
169
+ print: Printer,
170
+ mail: Mail,
171
+ bluetooth: Bluetooth,
172
+ collect: HandCoins,
173
+ invoice: Receipt,
174
+ delivery: Truck,
175
+ check: Check,
176
+ close: X,
177
+ send: Send,
178
+ power: Power,
179
+ home: House,
180
+ menu: Menu,
181
+ 'arrow-left': ArrowLeft,
182
+ 'arrow-right': ArrowRight,
183
+ 'chevron-down': ChevronDown,
184
+ 'chevron-right': ChevronRight,
185
+ 'chevron-up': ChevronUp,
186
+ dot: Dot,
187
+ sun: Sun,
188
+ moon: Moon,
189
+ bell: Bell,
190
+ 'log-out': LogOut,
191
+ settings: Settings,
192
+ search: Search,
193
+ users: Users,
194
+ 'file-text': FileText,
195
+ shield: Shield,
196
+ 'trending-up': TrendingUp,
197
+ banknote: Banknote,
198
+ 'bar-chart': ChartBar,
199
+ wallet: Wallet,
200
+ files: Files,
201
+ undo: Undo2,
202
+ 'arrow-up-right': ArrowUpRight,
203
+ upload: Upload,
204
+ 'clipboard-list': ClipboardList,
205
+ truck: Truck,
206
+ package: Package,
207
+ 'check-circle': CircleCheckBig,
208
+ refresh: RefreshCw,
209
+ 'shopping-cart': ShoppingCart,
210
+ tag: Tag,
211
+ bookmark: Bookmark,
212
+ contact: Contact,
213
+ 'map-pin': MapPin,
214
+ scale: Scale,
215
+ calendar: CalendarDays,
216
+ factory: Factory,
217
+ history: Clock,
218
+ 'credit-card': CreditCard,
219
+ book: BookText,
220
+ calculator: Calculator,
221
+ map: Map,
222
+ star: Star,
223
+ 'message-circle': MessageCircle,
224
+ user: User,
225
+ key: KeyRound,
226
+ 'file-edit': FilePenLine,
227
+ pencil: Pencil,
228
+ ban: Ban,
229
+ landmark: Landmark,
230
+ plus: Plus,
231
+ minus: Minus,
232
+ 'trash-2': Trash2,
233
+ 'scan-line': ScanLine,
234
+ eye: Eye,
235
+ 'folder-kanban': FolderKanban,
236
+ utensils: UtensilsCrossed,
237
+ zap: Zap,
238
+ shirt: Shirt,
239
+ wrench: Wrench,
240
+ pill: Pill,
241
+ dumbbell: Dumbbell,
242
+ car: Car,
243
+ sparkles: Sparkles,
244
+ sparkle: Sparkle,
245
+ gift: Gift,
246
+ pin: Pin,
247
+ };
248
+
249
+ export type IconProps = {
250
+ name: IconName;
251
+ size?: number;
252
+ color?: string;
253
+ strokeWidth?: number;
254
+ };
255
+
256
+ export function Icon({ name, size = 22, color = '#111827', strokeWidth = 2 }: IconProps) {
257
+ const Cmp = ICONS[name];
258
+ return <Cmp size={size} color={color} strokeWidth={strokeWidth} />;
259
+ }
@@ -0,0 +1,73 @@
1
+ import React from 'react';
2
+ import { Pressable, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { shadow } from '../theme/effects';
5
+ import { Icon, type IconName } from './Icon';
6
+
7
+ type IconButtonVariant = 'ghost' | 'glass' | 'solid' | 'danger';
8
+
9
+ type Props = {
10
+ icon: IconName;
11
+ onPress?: () => void;
12
+ accessibilityLabel: string;
13
+ size?: 'sm' | 'md' | 'lg';
14
+ variant?: IconButtonVariant;
15
+ disabled?: boolean;
16
+ selected?: boolean;
17
+ color?: string;
18
+ style?: StyleProp<ViewStyle>;
19
+ };
20
+
21
+ export function IconButton({
22
+ icon,
23
+ onPress,
24
+ accessibilityLabel,
25
+ size = 'md',
26
+ variant = 'glass',
27
+ disabled,
28
+ selected,
29
+ color,
30
+ style,
31
+ }: Props) {
32
+ const theme = useObiTheme();
33
+ const box = size === 'sm' ? 38 : size === 'lg' ? 50 : 44;
34
+ const iconSize = size === 'sm' ? 18 : size === 'lg' ? 24 : 20;
35
+ const backgroundColor = selected
36
+ ? theme.glass.primaryTintStrong
37
+ : variant === 'solid'
38
+ ? theme.colors.primary
39
+ : variant === 'danger'
40
+ ? theme.glass.dangerTint
41
+ : variant === 'glass'
42
+ ? theme.glass.control
43
+ : 'transparent';
44
+ const foreground = color ?? (variant === 'solid' ? theme.colors.onPrimary : variant === 'danger' ? theme.colors.danger : theme.colors.textStrong);
45
+
46
+ return (
47
+ <Pressable
48
+ accessibilityRole="button"
49
+ accessibilityLabel={accessibilityLabel}
50
+ accessibilityState={{ disabled: Boolean(disabled), selected: Boolean(selected) }}
51
+ disabled={disabled}
52
+ onPress={onPress}
53
+ hitSlop={8}
54
+ style={({ pressed }) => [
55
+ {
56
+ width: box,
57
+ height: box,
58
+ borderRadius: theme.radius.md,
59
+ alignItems: 'center',
60
+ justifyContent: 'center',
61
+ backgroundColor,
62
+ borderWidth: variant === 'glass' || selected ? 1 : 0,
63
+ borderColor: selected ? theme.colors.primary : theme.glass.border,
64
+ opacity: disabled ? 0.4 : pressed ? 0.72 : 1,
65
+ ...(variant === 'glass' ? shadow.glassSm : {}),
66
+ },
67
+ style,
68
+ ]}
69
+ >
70
+ <Icon name={icon} size={iconSize} color={foreground} />
71
+ </Pressable>
72
+ );
73
+ }
@@ -0,0 +1,134 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { shadow } from '../theme/effects';
5
+ import { BottomSheet } from './BottomSheet';
6
+ import { Icon } from './Icon';
7
+ import { TextField } from './TextField';
8
+
9
+ export type FilterOption = { value: string; label: string };
10
+
11
+ export type ListFilterBarProps = {
12
+ search?: string;
13
+ onSearchChange?: (value: string) => void;
14
+ searchPlaceholder?: string;
15
+ filterValue?: string;
16
+ onFilterChange?: (value: string) => void;
17
+ filterOptions?: FilterOption[];
18
+ filterTitle?: string;
19
+ extra?: React.ReactNode;
20
+ };
21
+
22
+ export function ListFilterBar({
23
+ search,
24
+ onSearchChange,
25
+ searchPlaceholder = 'Buscar…',
26
+ filterValue,
27
+ onFilterChange,
28
+ filterOptions,
29
+ filterTitle = 'Filtrar por estado',
30
+ extra,
31
+ }: ListFilterBarProps) {
32
+ const theme = useObiTheme();
33
+ const [open, setOpen] = useState(false);
34
+ const hasSearch = onSearchChange != null;
35
+ const hasFilter = Boolean(filterOptions?.length && onFilterChange);
36
+ const isFilterActive = hasFilter && filterValue != null && filterValue !== filterOptions![0].value;
37
+
38
+ return (
39
+ <View style={{ gap: theme.spacing.sm }}>
40
+ <View style={{ flexDirection: 'row', gap: theme.spacing.sm, alignItems: 'center' }}>
41
+ {hasSearch ? (
42
+ <View style={{ flex: 1 }}>
43
+ <TextField
44
+ value={search}
45
+ onChangeText={onSearchChange}
46
+ placeholder={searchPlaceholder}
47
+ autoCapitalize="none"
48
+ />
49
+ </View>
50
+ ) : null}
51
+ {hasFilter ? (
52
+ <Pressable
53
+ accessibilityRole="button"
54
+ accessibilityLabel={filterTitle}
55
+ accessibilityState={{ expanded: open, selected: isFilterActive }}
56
+ onPress={() => setOpen(true)}
57
+ hitSlop={8}
58
+ style={({ pressed }) => ({
59
+ width: 48,
60
+ height: 48,
61
+ borderRadius: theme.radius.md,
62
+ borderWidth: 1,
63
+ borderColor: isFilterActive ? theme.colors.primary : theme.glass.border,
64
+ backgroundColor: isFilterActive ? theme.glass.primaryTintStrong : theme.glass.surface,
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ opacity: pressed ? 0.76 : 1,
68
+ ...shadow.glassSm,
69
+ })}
70
+ >
71
+ <View style={{ gap: 3, alignItems: 'flex-start' }}>
72
+ {[16, 11, 6].map(width => (
73
+ <View
74
+ key={width}
75
+ style={{
76
+ width,
77
+ height: 2,
78
+ borderRadius: 1,
79
+ backgroundColor: isFilterActive ? theme.colors.primary : theme.colors.textMuted,
80
+ }}
81
+ />
82
+ ))}
83
+ </View>
84
+ </Pressable>
85
+ ) : null}
86
+ </View>
87
+ {extra}
88
+
89
+ {hasFilter ? (
90
+ <BottomSheet
91
+ visible={open}
92
+ onClose={() => setOpen(false)}
93
+ title={filterTitle}
94
+ minHeight={320}
95
+ contentStyle={{ gap: theme.spacing.xs, paddingTop: theme.spacing.sm, paddingBottom: theme.spacing.lg, flexGrow: 1 }}
96
+ >
97
+ {filterOptions!.map(option => {
98
+ const selected = option.value === filterValue;
99
+ return (
100
+ <Pressable
101
+ key={option.value}
102
+ onPress={() => {
103
+ onFilterChange!(option.value);
104
+ setOpen(false);
105
+ }}
106
+ style={({ pressed }) => ({
107
+ minHeight: 56,
108
+ paddingVertical: theme.spacing.lg,
109
+ flexDirection: 'row',
110
+ alignItems: 'center',
111
+ justifyContent: 'space-between',
112
+ borderBottomWidth: 1,
113
+ borderBottomColor: theme.glass.separator,
114
+ backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent',
115
+ })}
116
+ >
117
+ <Text
118
+ style={{
119
+ fontSize: theme.type.body,
120
+ color: selected ? theme.colors.primary : theme.colors.text,
121
+ fontWeight: selected ? '800' : '600',
122
+ }}
123
+ >
124
+ {option.label}
125
+ </Text>
126
+ {selected ? <Icon name="check" size={16} color={theme.colors.primary} /> : null}
127
+ </Pressable>
128
+ );
129
+ })}
130
+ </BottomSheet>
131
+ ) : null}
132
+ </View>
133
+ );
134
+ }
@@ -0,0 +1,198 @@
1
+ import React, { useCallback, useRef } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import Animated, { type SharedValue, useAnimatedStyle } from 'react-native-reanimated';
4
+ import Swipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable';
5
+ import { useObiTheme } from '../provider/ObiUIProvider';
6
+ import { Icon, type IconName } from './Icon';
7
+ import { StatusBadge, type StatusTone } from './StatusBadge';
8
+
9
+ export type SwipeActionTone = 'primary' | 'danger' | 'success' | 'warning' | 'neutral';
10
+
11
+ export type SwipeAction = {
12
+ key: string;
13
+ icon: IconName;
14
+ label: string;
15
+ tone?: SwipeActionTone;
16
+ onPress: () => void;
17
+ };
18
+
19
+ export type ListRowProps = {
20
+ title: string;
21
+ subtitle?: string;
22
+ captions?: (string | null | undefined | false)[];
23
+ amount?: string;
24
+ badgeLabel?: string;
25
+ badgeTone?: StatusTone;
26
+ onPress?: () => void;
27
+ footer?: React.ReactNode;
28
+ icon?: React.ReactNode;
29
+ swipeActions?: SwipeAction[];
30
+ };
31
+
32
+ const ACTION_WIDTH = 76;
33
+ const MAX_SWIPE_ACTIONS = 4;
34
+
35
+ let closeOpenSwipe: (() => void) | null = null;
36
+
37
+ function claimSwipeOpen(close: () => void) {
38
+ if (closeOpenSwipe && closeOpenSwipe !== close) closeOpenSwipe();
39
+ closeOpenSwipe = close;
40
+ }
41
+
42
+ function releaseSwipeOpen(close: () => void) {
43
+ if (closeOpenSwipe === close) closeOpenSwipe = null;
44
+ }
45
+
46
+ function SwipeActionsPanel({
47
+ actions,
48
+ progress,
49
+ methods,
50
+ }: {
51
+ actions: SwipeAction[];
52
+ progress: SharedValue<number>;
53
+ methods: SwipeableMethods;
54
+ }) {
55
+ const theme = useObiTheme();
56
+ const hideUntilSwipe = useAnimatedStyle(() => ({ opacity: progress.value <= 0.001 ? 0 : 1 }));
57
+ const toneColor = (tone: SwipeActionTone) => {
58
+ switch (tone) {
59
+ case 'primary': return theme.colors.primary;
60
+ case 'danger': return theme.colors.danger;
61
+ case 'success': return theme.colors.success;
62
+ case 'warning': return theme.colors.warning;
63
+ default: return theme.colors.textMuted;
64
+ }
65
+ };
66
+
67
+ return (
68
+ <Animated.View
69
+ style={[
70
+ { flexDirection: 'row', alignItems: 'stretch', height: '100%', width: ACTION_WIDTH * actions.length },
71
+ hideUntilSwipe,
72
+ ]}
73
+ >
74
+ {actions.map(action => {
75
+ const tone = action.tone ?? 'neutral';
76
+ return (
77
+ <Pressable
78
+ key={action.key}
79
+ accessibilityRole="button"
80
+ accessibilityLabel={action.label}
81
+ onPress={() => {
82
+ methods.close();
83
+ action.onPress();
84
+ }}
85
+ style={({ pressed }) => ({
86
+ width: ACTION_WIDTH,
87
+ alignItems: 'center',
88
+ justifyContent: 'center',
89
+ gap: 4,
90
+ paddingHorizontal: theme.spacing.xs,
91
+ backgroundColor: toneColor(tone),
92
+ opacity: pressed ? 0.85 : 1,
93
+ })}
94
+ >
95
+ <Icon name={action.icon} size={20} color="#FFFFFF" strokeWidth={2.25} />
96
+ <Text style={{ fontSize: 10, fontWeight: '700', color: '#FFFFFF', textAlign: 'center' }} numberOfLines={1}>
97
+ {action.label}
98
+ </Text>
99
+ </Pressable>
100
+ );
101
+ })}
102
+ </Animated.View>
103
+ );
104
+ }
105
+
106
+ export function ListRow({
107
+ title,
108
+ subtitle,
109
+ captions,
110
+ amount,
111
+ badgeLabel,
112
+ badgeTone = 'neutral',
113
+ onPress,
114
+ footer,
115
+ icon,
116
+ swipeActions,
117
+ }: ListRowProps) {
118
+ const theme = useObiTheme();
119
+ const visibleCaptions = (captions ?? []).filter(Boolean) as string[];
120
+ const actions = (swipeActions ?? []).slice(0, MAX_SWIPE_ACTIONS);
121
+ const swipeRef = useRef<SwipeableMethods>(null);
122
+ const closeSelf = useCallback(() => swipeRef.current?.close(), []);
123
+
124
+ const content = (
125
+ <>
126
+ {icon ? (
127
+ <View style={{ width: 38, height: 38, borderRadius: theme.radius.md, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.glass.primaryTint }}>
128
+ {icon}
129
+ </View>
130
+ ) : null}
131
+ <View style={{ flex: 1, gap: 3 }}>
132
+ <Text style={{ fontSize: theme.type.body, fontWeight: '700', color: theme.colors.text }} numberOfLines={1}>{title}</Text>
133
+ {subtitle ? <Text style={{ fontSize: 13, color: theme.colors.textMuted }} numberOfLines={1}>{subtitle}</Text> : null}
134
+ {visibleCaptions.length > 0 ? (
135
+ <View style={{ flexDirection: 'row', gap: theme.spacing.sm, flexWrap: 'wrap' }}>
136
+ {visibleCaptions.map((caption, index) => (
137
+ <Text key={`${caption}-${index}`} style={{ fontSize: 11, color: theme.colors.textMuted }}>{caption}</Text>
138
+ ))}
139
+ </View>
140
+ ) : null}
141
+ </View>
142
+ {amount || badgeLabel ? (
143
+ <View style={{ alignItems: 'flex-end', gap: theme.spacing.xs }}>
144
+ {amount ? <Text style={{ fontSize: theme.type.body, fontWeight: '800', color: theme.colors.text }}>{amount}</Text> : null}
145
+ {badgeLabel ? <StatusBadge label={badgeLabel} tone={badgeTone} /> : null}
146
+ </View>
147
+ ) : null}
148
+ </>
149
+ );
150
+
151
+ const itemStyle = ({ pressed }: { pressed: boolean }) => [
152
+ {
153
+ flexDirection: 'row' as const,
154
+ alignItems: 'center' as const,
155
+ padding: theme.spacing.lg,
156
+ gap: theme.spacing.md,
157
+ backgroundColor: actions.length ? theme.colors.surface : pressed ? theme.colors.surfacePressed : 'transparent',
158
+ paddingBottom: footer && !actions.length ? theme.spacing.sm : theme.spacing.lg,
159
+ },
160
+ ];
161
+
162
+ const row = onPress ? (
163
+ <Pressable onPress={onPress} style={itemStyle}>{content}</Pressable>
164
+ ) : (
165
+ <View style={itemStyle({ pressed: false })}>{content}</View>
166
+ );
167
+
168
+ if (actions.length === 0) {
169
+ return (
170
+ <View style={{ borderBottomWidth: 1, borderBottomColor: theme.colors.separator, backgroundColor: theme.glass.surface }}>
171
+ {row}
172
+ {footer ? (
173
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.sm, paddingHorizontal: theme.spacing.lg, paddingBottom: theme.spacing.md }}>
174
+ {footer}
175
+ </View>
176
+ ) : null}
177
+ </View>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <Swipeable
183
+ ref={swipeRef}
184
+ friction={2}
185
+ rightThreshold={40}
186
+ overshootRight={false}
187
+ containerStyle={{ borderBottomWidth: 1, borderBottomColor: theme.colors.separator, backgroundColor: theme.colors.surface, overflow: 'hidden' }}
188
+ childrenContainerStyle={{ backgroundColor: theme.colors.surface }}
189
+ onSwipeableWillOpen={() => claimSwipeOpen(closeSelf)}
190
+ onSwipeableClose={() => releaseSwipeOpen(closeSelf)}
191
+ renderRightActions={(progress, _translation, methods) => (
192
+ <SwipeActionsPanel actions={actions} progress={progress} methods={methods} />
193
+ )}
194
+ >
195
+ {row}
196
+ </Swipeable>
197
+ );
198
+ }