@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.
- package/README.md +45 -0
- package/package.json +52 -0
- package/src/components/AppHeader.tsx +67 -0
- package/src/components/BottomSheet.tsx +110 -0
- package/src/components/Button.tsx +117 -0
- package/src/components/Card.tsx +46 -0
- package/src/components/CenteredState.tsx +35 -0
- package/src/components/Chip.tsx +47 -0
- package/src/components/ChoiceField.tsx +44 -0
- package/src/components/DateRangeFilter.tsx +226 -0
- package/src/components/EmptyState.tsx +32 -0
- package/src/components/ErrorState.tsx +62 -0
- package/src/components/Fab.tsx +36 -0
- package/src/components/FormRow.tsx +39 -0
- package/src/components/FormWorkspaceShell.tsx +126 -0
- package/src/components/GlassTabBar.tsx +39 -0
- package/src/components/Icon.tsx +259 -0
- package/src/components/IconButton.tsx +73 -0
- package/src/components/ListFilterBar.tsx +134 -0
- package/src/components/ListRow.tsx +198 -0
- package/src/components/ListWorkspace.tsx +76 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/MetricCard.tsx +74 -0
- package/src/components/MetricsGrid.tsx +25 -0
- package/src/components/MiniBarChart.tsx +42 -0
- package/src/components/NavigationDrawer.tsx +243 -0
- package/src/components/QueryState.tsx +82 -0
- package/src/components/QueryWorkspace.tsx +74 -0
- package/src/components/ScanInputBar.tsx +69 -0
- package/src/components/ScannerShell.tsx +76 -0
- package/src/components/Screen.tsx +44 -0
- package/src/components/SearchArea.tsx +34 -0
- package/src/components/SegmentedControl.tsx +64 -0
- package/src/components/Skeleton.tsx +42 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/SuccessState.tsx +41 -0
- package/src/components/TextField.tsx +47 -0
- package/src/components/ToggleRow.tsx +35 -0
- package/src/components/record-detail/RecordDetail.tsx +60 -0
- package/src/components/record-detail/RecordDetailActions.tsx +88 -0
- package/src/components/record-detail/RecordDetailField.tsx +43 -0
- package/src/components/record-detail/RecordDetailHero.tsx +46 -0
- package/src/components/record-detail/RecordDetailLine.tsx +36 -0
- package/src/components/record-detail/RecordDetailSection.tsx +36 -0
- package/src/components/record-detail/index.ts +7 -0
- package/src/components/record-detail/status.ts +16 -0
- package/src/gallery/catalog.ts +31 -0
- package/src/glass/AmbientBackground.tsx +29 -0
- package/src/glass/GlassSurface.tsx +107 -0
- package/src/index.ts +66 -0
- package/src/layout/Box.tsx +62 -0
- package/src/layout/Divider.tsx +8 -0
- package/src/layout/Row.tsx +7 -0
- package/src/layout/Stack.tsx +7 -0
- package/src/provider/ObiUIProvider.tsx +45 -0
- package/src/styles/useObiStyles.ts +288 -0
- package/src/theme/effects.ts +219 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/theme.ts +53 -0
- package/src/theme/tokens.ts +133 -0
- 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
|
+
}
|