@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,69 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { ActivityIndicator, TextInput, View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { useObiStyles } from '../styles/useObiStyles';
5
+ import { ObiText } from '../typography/ObiText';
6
+ import { Button } from './Button';
7
+
8
+ export type ScanInputBarProps = {
9
+ onSubmit: (code: string) => Promise<boolean> | boolean;
10
+ onOpenCamera: () => void;
11
+ loading?: boolean;
12
+ autoFocus?: boolean;
13
+ label?: string;
14
+ placeholder?: string;
15
+ cameraLabel?: string;
16
+ };
17
+
18
+ export function ScanInputBar({
19
+ onSubmit,
20
+ onOpenCamera,
21
+ loading = false,
22
+ autoFocus = false,
23
+ label = 'ESCÁNER / CÓDIGO',
24
+ placeholder = 'Escanee o escriba y presione Enter',
25
+ cameraLabel = 'Cámara',
26
+ }: ScanInputBarProps) {
27
+ const theme = useObiTheme();
28
+ const ui = useObiStyles();
29
+ const [code, setCode] = useState('');
30
+ const inputRef = useRef<TextInput>(null);
31
+
32
+ useEffect(() => {
33
+ if (!autoFocus) return;
34
+ const timer = setTimeout(() => inputRef.current?.focus(), 80);
35
+ return () => clearTimeout(timer);
36
+ }, [autoFocus]);
37
+
38
+ const submit = async () => {
39
+ const value = code.trim();
40
+ if (!value || loading) return;
41
+ const success = await onSubmit(value);
42
+ if (success) setCode('');
43
+ setTimeout(() => inputRef.current?.focus(), 60);
44
+ };
45
+
46
+ return (
47
+ <View style={ui.formSection}>
48
+ <View style={ui.scanInputWrap}>
49
+ <ObiText variant="caption" weight="800" muted>{label}</ObiText>
50
+ <View style={ui.scanInputRow}>
51
+ <TextInput
52
+ ref={inputRef}
53
+ value={code}
54
+ onChangeText={setCode}
55
+ placeholder={placeholder}
56
+ placeholderTextColor={theme.colors.textMuted}
57
+ autoCapitalize="characters"
58
+ autoCorrect={false}
59
+ returnKeyType="search"
60
+ onSubmitEditing={() => void submit()}
61
+ style={ui.input}
62
+ />
63
+ {loading ? <ActivityIndicator color={theme.colors.primary} /> : null}
64
+ </View>
65
+ </View>
66
+ <Button title={cameraLabel} variant="secondary" onPress={onOpenCamera} />
67
+ </View>
68
+ );
69
+ }
@@ -0,0 +1,76 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { ObiText } from '../typography/ObiText';
5
+ import { GlassSurface } from '../glass/GlassSurface';
6
+ import { Button } from './Button';
7
+
8
+ export type ScannerShellProps = {
9
+ children?: React.ReactNode;
10
+ title?: string;
11
+ status?: string;
12
+ processing?: boolean;
13
+ torch?: boolean;
14
+ onClose: () => void;
15
+ onToggleTorch?: () => void;
16
+ style?: StyleProp<ViewStyle>;
17
+ };
18
+
19
+ /** Chrome layer for camera/scanner screens. The camera remains owned by the app. */
20
+ export function ScannerShell({
21
+ children,
22
+ title = 'Escanear',
23
+ status,
24
+ processing,
25
+ torch,
26
+ onClose,
27
+ onToggleTorch,
28
+ style,
29
+ }: ScannerShellProps) {
30
+ const theme = useObiTheme();
31
+ return (
32
+ <View style={[{ flex: 1, backgroundColor: '#000' }, style]}>
33
+ {children}
34
+
35
+ <View style={{
36
+ paddingTop: theme.spacing.xl,
37
+ paddingHorizontal: theme.spacing.lg,
38
+ paddingBottom: theme.spacing.xl,
39
+ gap: theme.spacing.sm,
40
+ backgroundColor: 'rgba(0,0,0,0.36)',
41
+ }}>
42
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: theme.spacing.md }}>
43
+ <Button title="Cerrar" variant="glass" size="sm" onPress={onClose} />
44
+ {onToggleTorch ? (
45
+ <Button
46
+ title={torch ? 'Apagar luz' : 'Encender luz'}
47
+ variant="glass"
48
+ size="sm"
49
+ onPress={onToggleTorch}
50
+ />
51
+ ) : null}
52
+ </View>
53
+ <ObiText weight="900" style={{ color: '#FFFFFF', textAlign: 'center' }}>{title}</ObiText>
54
+ </View>
55
+
56
+ {status ? (
57
+ <GlassSurface
58
+ variant="scanner"
59
+ style={{
60
+ position: 'absolute',
61
+ left: theme.spacing.md,
62
+ right: theme.spacing.md,
63
+ bottom: theme.spacing.md,
64
+ borderRadius: theme.radius.xl,
65
+ padding: theme.spacing.lg,
66
+ }}
67
+ >
68
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.md }}>
69
+ {processing ? <ActivityIndicator color={theme.colors.primary} /> : null}
70
+ <ObiText weight="700" style={{ flex: 1 }}>{status}</ObiText>
71
+ </View>
72
+ </GlassSurface>
73
+ ) : null}
74
+ </View>
75
+ );
76
+ }
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { SafeAreaView, type Edge, useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useObiTheme } from '../provider/ObiUIProvider';
5
+
6
+ export function Screen({
7
+ children,
8
+ style,
9
+ edges = [],
10
+ tabClearance,
11
+ navigationClearance,
12
+ opaque = false,
13
+ }: {
14
+ children: React.ReactNode;
15
+ style?: StyleProp<ViewStyle>;
16
+ edges?: Edge[];
17
+ tabClearance?: boolean;
18
+ navigationClearance?: boolean | number;
19
+ opaque?: boolean;
20
+ }) {
21
+ const insets = useSafeAreaInsets();
22
+ const theme = useObiTheme();
23
+ const clearTab = navigationClearance ?? tabClearance ?? !edges.includes('bottom');
24
+ const clearance = clearTab === true
25
+ ? theme.navigation.bottomBarHeight
26
+ : typeof clearTab === 'number'
27
+ ? clearTab
28
+ : 0;
29
+ const backgroundColor = opaque ? theme.colors.background : 'transparent';
30
+
31
+ return (
32
+ <SafeAreaView edges={edges} style={{ flex: 1, backgroundColor }}>
33
+ <View
34
+ style={[
35
+ { flex: 1, backgroundColor },
36
+ clearTab ? { paddingBottom: clearance + insets.bottom } : null,
37
+ style,
38
+ ]}
39
+ >
40
+ {children}
41
+ </View>
42
+ </SafeAreaView>
43
+ );
44
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { SoftGlassPanel } from '../glass/GlassSurface';
5
+
6
+ export function SearchArea({
7
+ children,
8
+ style,
9
+ dense = true,
10
+ }: {
11
+ children: React.ReactNode;
12
+ style?: StyleProp<ViewStyle>;
13
+ dense?: boolean;
14
+ }) {
15
+ const theme = useObiTheme();
16
+ return (
17
+ <SoftGlassPanel
18
+ dense={dense}
19
+ style={[
20
+ {
21
+ paddingHorizontal: theme.spacing.lg,
22
+ paddingVertical: theme.spacing.sm,
23
+ backgroundColor: 'transparent',
24
+ borderBottomWidth: 1,
25
+ borderBottomColor: theme.glass.borderSoft,
26
+ gap: theme.spacing.sm,
27
+ },
28
+ style,
29
+ ]}
30
+ >
31
+ {children}
32
+ </SoftGlassPanel>
33
+ );
34
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { shadow } from '../theme/effects';
5
+ import { ObiText } from '../typography/ObiText';
6
+
7
+ export type SegmentedOption<T extends string | number> = { value: T; label: string };
8
+
9
+ export function SegmentedControl<T extends string | number>({
10
+ options,
11
+ value,
12
+ onChange,
13
+ }: {
14
+ options: Array<SegmentedOption<T>>;
15
+ value: T;
16
+ onChange: (value: T) => void;
17
+ }) {
18
+ const theme = useObiTheme();
19
+ return (
20
+ <View style={{
21
+ flexDirection: 'row',
22
+ backgroundColor: theme.glass.surface,
23
+ borderRadius: theme.radius.md,
24
+ padding: 3,
25
+ gap: 2,
26
+ borderWidth: 1,
27
+ borderColor: theme.glass.border,
28
+ ...shadow.glassSm,
29
+ }}>
30
+ {options.map(option => {
31
+ const selected = option.value === value;
32
+ return (
33
+ <Pressable
34
+ key={String(option.value)}
35
+ accessibilityRole="button"
36
+ accessibilityState={{ selected }}
37
+ onPress={() => onChange(option.value)}
38
+ style={({ pressed }) => ({
39
+ flex: 1,
40
+ minHeight: 40,
41
+ alignItems: 'center',
42
+ justifyContent: 'center',
43
+ borderRadius: theme.radius.sm,
44
+ paddingHorizontal: theme.spacing.sm,
45
+ backgroundColor: selected ? theme.glass.surfaceStrong : 'transparent',
46
+ opacity: pressed ? 0.78 : 1,
47
+ })}
48
+ >
49
+ <ObiText
50
+ weight={selected ? '800' : '700'}
51
+ style={{
52
+ fontSize: 12,
53
+ color: selected ? theme.colors.primary : theme.colors.textMuted,
54
+ letterSpacing: 0.2,
55
+ }}
56
+ >
57
+ {option.label}
58
+ </ObiText>
59
+ </Pressable>
60
+ );
61
+ })}
62
+ </View>
63
+ );
64
+ }
@@ -0,0 +1,42 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Animated, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+
5
+ export function Skeleton({
6
+ width,
7
+ height,
8
+ style,
9
+ radius,
10
+ }: {
11
+ width?: number | `${number}%`;
12
+ height: number;
13
+ style?: StyleProp<ViewStyle>;
14
+ radius?: number;
15
+ }) {
16
+ const theme = useObiTheme();
17
+ const opacity = useRef(new Animated.Value(0.4)).current;
18
+
19
+ useEffect(() => {
20
+ const pulse = Animated.loop(Animated.sequence([
21
+ Animated.timing(opacity, { toValue: 1, duration: 700, useNativeDriver: true }),
22
+ Animated.timing(opacity, { toValue: 0.4, duration: 700, useNativeDriver: true }),
23
+ ]));
24
+ pulse.start();
25
+ return () => pulse.stop();
26
+ }, [opacity]);
27
+
28
+ return (
29
+ <Animated.View
30
+ style={[
31
+ {
32
+ width,
33
+ height,
34
+ borderRadius: radius ?? theme.radius.sm,
35
+ backgroundColor: theme.colors.surfaceMuted,
36
+ opacity,
37
+ },
38
+ style,
39
+ ]}
40
+ />
41
+ );
42
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+
5
+ export type StatusTone = 'success' | 'warning' | 'danger' | 'neutral' | 'primary';
6
+
7
+ export function StatusBadge({ label, tone = 'neutral' }: { label: string; tone?: StatusTone }) {
8
+ const theme = useObiTheme();
9
+ const color = tone === 'success'
10
+ ? theme.colors.success
11
+ : tone === 'warning'
12
+ ? theme.colors.warning
13
+ : tone === 'danger'
14
+ ? theme.colors.danger
15
+ : tone === 'primary'
16
+ ? theme.colors.primary
17
+ : theme.colors.textMuted;
18
+ return (
19
+ <View style={{ borderRadius: theme.radius.pill, paddingHorizontal: theme.spacing.sm, paddingVertical: 2, backgroundColor: `${color}18` }}>
20
+ <Text style={{ fontSize: 11, fontWeight: '700', color }} numberOfLines={1}>{label}</Text>
21
+ </View>
22
+ );
23
+ }
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { Card } from './Card';
5
+ import { Icon, type IconName } from './Icon';
6
+ import { ObiText } from '../typography/ObiText';
7
+
8
+ export function SuccessState({
9
+ title,
10
+ description,
11
+ icon = 'check',
12
+ actions,
13
+ }: {
14
+ title: string;
15
+ description?: string;
16
+ icon?: IconName;
17
+ actions?: React.ReactNode;
18
+ }) {
19
+ const theme = useObiTheme();
20
+ return (
21
+ <Card style={{ width: '100%', maxWidth: 520, alignItems: 'center', gap: theme.spacing.lg }}>
22
+ <View
23
+ style={{
24
+ width: 72,
25
+ height: 72,
26
+ borderRadius: theme.radius.pill,
27
+ backgroundColor: theme.glass.successTint,
28
+ alignItems: 'center',
29
+ justifyContent: 'center',
30
+ borderWidth: 1,
31
+ borderColor: theme.colors.success + '33',
32
+ }}
33
+ >
34
+ <Icon name={icon} size={36} color={theme.colors.success} strokeWidth={3} />
35
+ </View>
36
+ <ObiText variant="title" weight="900" style={{ textAlign: 'center' }}>{title}</ObiText>
37
+ {description ? <ObiText muted style={{ textAlign: 'center' }}>{description}</ObiText> : null}
38
+ {actions ? <View style={{ width: '100%', gap: theme.spacing.sm }}>{actions}</View> : null}
39
+ </Card>
40
+ );
41
+ }
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { TextInput, View, type TextInputProps } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { shadow } from '../theme/effects';
5
+ import { ObiText } from '../typography/ObiText';
6
+
7
+ export function TextField({
8
+ label,
9
+ helperText,
10
+ error,
11
+ ...props
12
+ }: TextInputProps & {
13
+ label?: string;
14
+ helperText?: string;
15
+ error?: string;
16
+ }) {
17
+ const theme = useObiTheme();
18
+ return (
19
+ <View style={{ gap: theme.spacing.xs }}>
20
+ {label ? <ObiText variant="caption" weight="700">{label}</ObiText> : null}
21
+ <TextInput
22
+ placeholderTextColor={theme.colors.textMuted}
23
+ {...props}
24
+ style={[
25
+ {
26
+ minHeight: 48,
27
+ borderWidth: 1,
28
+ borderColor: error ? theme.colors.danger : theme.glass.border,
29
+ borderRadius: theme.radius.md,
30
+ backgroundColor: theme.glass.input,
31
+ paddingHorizontal: theme.spacing.md,
32
+ paddingVertical: theme.spacing.sm,
33
+ color: theme.colors.text,
34
+ fontSize: theme.type.body,
35
+ ...shadow.glassSm,
36
+ },
37
+ props.style,
38
+ ]}
39
+ />
40
+ {error ? (
41
+ <ObiText variant="caption" tone="danger">{error}</ObiText>
42
+ ) : helperText ? (
43
+ <ObiText variant="caption" muted>{helperText}</ObiText>
44
+ ) : null}
45
+ </View>
46
+ );
47
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { Switch, View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { ObiText } from '../typography/ObiText';
5
+
6
+ export function ToggleRow({
7
+ label,
8
+ description,
9
+ value,
10
+ onValueChange,
11
+ disabled,
12
+ }: {
13
+ label: string;
14
+ description?: string;
15
+ value: boolean;
16
+ onValueChange: (value: boolean) => void;
17
+ disabled?: boolean;
18
+ }) {
19
+ const theme = useObiTheme();
20
+ return (
21
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: theme.spacing.lg }}>
22
+ <View style={{ flex: 1, gap: theme.spacing.xs }}>
23
+ <ObiText weight="800">{label}</ObiText>
24
+ {description ? <ObiText variant="caption" muted>{description}</ObiText> : null}
25
+ </View>
26
+ <Switch
27
+ value={value}
28
+ onValueChange={onValueChange}
29
+ disabled={disabled}
30
+ trackColor={{ false: theme.colors.borderStrong, true: theme.colors.primarySoft }}
31
+ thumbColor={value ? theme.colors.primary : undefined}
32
+ />
33
+ </View>
34
+ );
35
+ }
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, ScrollView } from 'react-native';
3
+ import { useObiTheme } from '../../provider/ObiUIProvider';
4
+ import { ObiText } from '../../typography/ObiText';
5
+ import { Button } from '../Button';
6
+ import { Screen } from '../Screen';
7
+ import { RecordDetailSection } from './RecordDetailSection';
8
+
9
+ export function RecordDetailNote({ text }: { text?: string | null }) {
10
+ const theme = useObiTheme();
11
+ if (!text) return null;
12
+ return (
13
+ <RecordDetailSection title="Nota">
14
+ <ObiText style={{ paddingVertical: theme.spacing.md, lineHeight: 22 }}>{text}</ObiText>
15
+ </RecordDetailSection>
16
+ );
17
+ }
18
+
19
+ export function RecordDetail({
20
+ loading,
21
+ error,
22
+ onRetry,
23
+ children,
24
+ }: {
25
+ loading?: boolean;
26
+ error?: string | null;
27
+ onRetry?: () => void;
28
+ children?: React.ReactNode;
29
+ }) {
30
+ const theme = useObiTheme();
31
+ const center = { alignItems: 'center' as const, justifyContent: 'center' as const, gap: theme.spacing.lg, padding: theme.spacing.xl };
32
+
33
+ if (loading) {
34
+ return (
35
+ <Screen style={center}>
36
+ <ActivityIndicator size="large" color={theme.colors.primary} />
37
+ </Screen>
38
+ );
39
+ }
40
+
41
+ if (error) {
42
+ return (
43
+ <Screen style={center}>
44
+ <ObiText style={{ color: theme.colors.danger, textAlign: 'center' }}>{error}</ObiText>
45
+ {onRetry ? <Button title="Reintentar" onPress={onRetry} /> : null}
46
+ </Screen>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <Screen>
52
+ <ScrollView
53
+ contentContainerStyle={{ paddingBottom: 128, width: '100%', maxWidth: 720, alignSelf: 'center' }}
54
+ showsVerticalScrollIndicator={false}
55
+ >
56
+ {children}
57
+ </ScrollView>
58
+ </Screen>
59
+ );
60
+ }
@@ -0,0 +1,88 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Pressable, View } from 'react-native';
3
+ import { useObiTheme } from '../../provider/ObiUIProvider';
4
+ import { shadow } from '../../theme/effects';
5
+ import { ObiText } from '../../typography/ObiText';
6
+ import { Icon, type IconName } from '../Icon';
7
+
8
+ export type RecordAction = {
9
+ key: string;
10
+ icon: IconName;
11
+ label: string;
12
+ onPress: () => void;
13
+ disabled?: boolean;
14
+ loading?: boolean;
15
+ tone?: 'default' | 'primary' | 'danger';
16
+ };
17
+
18
+ export function RecordDetailActions({ actions }: { actions: RecordAction[] }) {
19
+ const theme = useObiTheme();
20
+ if (actions.length === 0) return null;
21
+ const compact = actions.length <= 5;
22
+
23
+ return (
24
+ <View
25
+ style={{
26
+ flexDirection: 'row',
27
+ flexWrap: 'wrap',
28
+ justifyContent: 'center',
29
+ paddingHorizontal: theme.spacing.xs,
30
+ paddingVertical: theme.spacing.sm,
31
+ marginHorizontal: theme.spacing.lg,
32
+ marginBottom: theme.spacing.md,
33
+ backgroundColor: theme.glass.surfaceStrong,
34
+ borderRadius: theme.radius.lg,
35
+ borderWidth: 1,
36
+ borderColor: theme.glass.border,
37
+ ...shadow.sm,
38
+ }}
39
+ >
40
+ {actions.map(action => {
41
+ const tone = action.tone ?? 'default';
42
+ const inert = Boolean(action.disabled || action.loading);
43
+ const iconColor = tone === 'primary' ? theme.colors.onPrimary : tone === 'danger' ? theme.colors.danger : theme.colors.primary;
44
+ return (
45
+ <Pressable
46
+ key={action.key}
47
+ onPress={action.onPress}
48
+ disabled={inert}
49
+ hitSlop={6}
50
+ style={({ pressed }) => ({
51
+ ...(compact
52
+ ? { flexGrow: 1, flexBasis: 56, maxWidth: 76 }
53
+ : { width: '25%' }),
54
+ alignItems: 'center',
55
+ gap: 6,
56
+ paddingVertical: theme.spacing.sm,
57
+ opacity: inert ? 0.45 : pressed ? 0.72 : 1,
58
+ })}
59
+ >
60
+ <View
61
+ style={{
62
+ width: 46,
63
+ height: 46,
64
+ borderRadius: 23,
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ backgroundColor: tone === 'primary'
68
+ ? theme.colors.primary
69
+ : tone === 'danger'
70
+ ? theme.glass.dangerTint
71
+ : theme.glass.primaryTint,
72
+ }}
73
+ >
74
+ {action.loading ? (
75
+ <ActivityIndicator size="small" color={tone === 'primary' ? theme.colors.onPrimary : theme.colors.primary} />
76
+ ) : (
77
+ <Icon name={action.icon} color={iconColor} size={20} />
78
+ )}
79
+ </View>
80
+ <ObiText variant="caption" weight="700" style={{ textAlign: 'center', color: theme.colors.text, lineHeight: 14 }} numberOfLines={2}>
81
+ {action.label}
82
+ </ObiText>
83
+ </Pressable>
84
+ );
85
+ })}
86
+ </View>
87
+ );
88
+ }
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { useObiTheme } from '../../provider/ObiUIProvider';
4
+ import { ObiText } from '../../typography/ObiText';
5
+
6
+ export function RecordDetailField({
7
+ label,
8
+ value,
9
+ emphasize,
10
+ last,
11
+ }: {
12
+ label: string;
13
+ value?: React.ReactNode;
14
+ emphasize?: boolean;
15
+ last?: boolean;
16
+ }) {
17
+ const theme = useObiTheme();
18
+ return (
19
+ <View
20
+ style={{
21
+ flexDirection: 'row',
22
+ alignItems: 'center',
23
+ justifyContent: 'space-between',
24
+ gap: theme.spacing.lg,
25
+ paddingVertical: theme.spacing.md,
26
+ borderBottomWidth: last ? 0 : 1,
27
+ borderBottomColor: theme.colors.border,
28
+ }}
29
+ >
30
+ <ObiText muted style={{ flexShrink: 0, maxWidth: '42%' }}>{label}</ObiText>
31
+ {typeof value === 'string' || value == null ? (
32
+ <ObiText
33
+ weight={emphasize ? '800' : '700'}
34
+ style={{ flex: 1, textAlign: 'right', ...(emphasize ? { fontSize: 20, letterSpacing: -0.3 } : {}) }}
35
+ >
36
+ {value || '—'}
37
+ </ObiText>
38
+ ) : (
39
+ <View style={{ flex: 1, alignItems: 'flex-end' }}>{value}</View>
40
+ )}
41
+ </View>
42
+ );
43
+ }