@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,226 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Platform, Pressable, Text, View } from 'react-native';
3
+ import DateTimePicker from '@react-native-community/datetimepicker';
4
+ import { useObiTheme } from '../provider/ObiUIProvider';
5
+ import { useObiStyles } from '../styles/useObiStyles';
6
+ import { BottomSheet } from './BottomSheet';
7
+ import { Icon } from './Icon';
8
+
9
+ export interface DateRangeValue {
10
+ from: string;
11
+ to: string;
12
+ }
13
+
14
+ export type DatePreset = 'today' | '7d' | 'month' | 'custom';
15
+ export type DateFilterOption = { value: string; label: string };
16
+
17
+ const iso = (date: Date) => date.toISOString().slice(0, 10);
18
+
19
+ const parse = (value: string) => {
20
+ const parts = value.split('-').map(Number);
21
+ return new Date(parts[0] || 2000, Math.max(0, (parts[1] || 1) - 1), parts[2] || 1, 12);
22
+ };
23
+
24
+ const today = () => iso(new Date());
25
+
26
+ const daysAgo = (days: number) => {
27
+ const d = new Date();
28
+ d.setDate(d.getDate() - days);
29
+ return iso(d);
30
+ };
31
+
32
+ const monthStart = () => {
33
+ const d = new Date();
34
+ d.setDate(1);
35
+ return iso(d);
36
+ };
37
+
38
+ const formatDisplay = (value: string, locale: string) =>
39
+ parse(value).toLocaleDateString(locale, {
40
+ day: '2-digit',
41
+ month: 'short',
42
+ year: 'numeric',
43
+ });
44
+
45
+ const PRESETS: { value: DatePreset; label: string }[] = [
46
+ { value: 'today', label: 'Hoy' },
47
+ { value: '7d', label: 'Últimos 7 días' },
48
+ { value: 'month', label: 'Este mes' },
49
+ { value: 'custom', label: 'Personalizado…' },
50
+ ];
51
+
52
+ export type DateRangeFilterProps = {
53
+ value: DateRangeValue;
54
+ onChange: (value: DateRangeValue) => void;
55
+ filterTitle?: string;
56
+ scopeValue?: string;
57
+ onScopeChange?: (value: string) => void;
58
+ scopeOptions?: DateFilterOption[];
59
+ scopeTitle?: string;
60
+ locale?: string;
61
+ };
62
+
63
+ /**
64
+ * Reusable date-range control with preset/scope bottom sheets.
65
+ * Visuals and theme resolution live entirely in @obisoft/ui.
66
+ */
67
+ export function DateRangeFilter({
68
+ value,
69
+ onChange,
70
+ filterTitle = 'Período',
71
+ scopeValue,
72
+ onScopeChange,
73
+ scopeOptions,
74
+ scopeTitle = 'Alcance',
75
+ locale = 'es-DO',
76
+ }: DateRangeFilterProps) {
77
+ const theme = useObiTheme();
78
+ const ui = useObiStyles();
79
+ const [presetOpen, setPresetOpen] = useState(false);
80
+ const [customOpen, setCustomOpen] = useState(false);
81
+ const [picker, setPicker] = useState<'from' | 'to' | null>(null);
82
+
83
+ const preset = useMemo<DatePreset>(() => {
84
+ if (value.from === today() && value.to === today()) return 'today';
85
+ if (value.from === daysAgo(6) && value.to === today()) return '7d';
86
+ if (value.from === monthStart() && value.to === today()) return 'month';
87
+ return 'custom';
88
+ }, [value]);
89
+
90
+ const hasScope = Boolean(scopeOptions?.length && onScopeChange);
91
+ const isFilterActive = preset !== 'today' || (hasScope && scopeValue !== scopeOptions?.[0]?.value);
92
+
93
+ const applyPreset = (kind: DatePreset) => {
94
+ if (kind === 'today') onChange({ from: today(), to: today() });
95
+ if (kind === '7d') onChange({ from: daysAgo(6), to: today() });
96
+ if (kind === 'month') onChange({ from: monthStart(), to: today() });
97
+ if (kind === 'custom') {
98
+ setPresetOpen(false);
99
+ setCustomOpen(true);
100
+ return;
101
+ }
102
+ setPresetOpen(false);
103
+ };
104
+
105
+ const rangeLabel = `${formatDisplay(value.from, locale)} — ${formatDisplay(value.to, locale)}`;
106
+
107
+ return (
108
+ <View style={ui.filterWrap}>
109
+ <View style={ui.filterRowInner}>
110
+ <Pressable
111
+ accessibilityRole="button"
112
+ accessibilityLabel="Rango de fechas"
113
+ onPress={() => setCustomOpen(true)}
114
+ style={({ pressed }) => [ui.filterSearch, ui.input, ui.dateRangeField, pressed && ui.listItemPressed]}
115
+ >
116
+ <Text style={ui.dateRangeFieldText} numberOfLines={1}>{rangeLabel}</Text>
117
+ </Pressable>
118
+
119
+ <Pressable
120
+ accessibilityRole="button"
121
+ accessibilityLabel={filterTitle}
122
+ accessibilityState={{ expanded: presetOpen }}
123
+ onPress={() => setPresetOpen(true)}
124
+ style={({ pressed }) => [ui.filterBtn, isFilterActive && ui.filterBtnActive, pressed && ui.listItemPressed]}
125
+ hitSlop={8}
126
+ >
127
+ <View style={ui.filterIcon}>
128
+ <View style={[ui.filterBarMark, { width: 16 }, isFilterActive && ui.filterBarMarkActive]} />
129
+ <View style={[ui.filterBarMark, { width: 11 }, isFilterActive && ui.filterBarMarkActive]} />
130
+ <View style={[ui.filterBarMark, { width: 6 }, isFilterActive && ui.filterBarMarkActive]} />
131
+ </View>
132
+ </Pressable>
133
+ </View>
134
+
135
+ <BottomSheet
136
+ visible={presetOpen}
137
+ onClose={() => setPresetOpen(false)}
138
+ title={filterTitle}
139
+ minHeight={hasScope ? 420 : 320}
140
+ contentStyle={ui.filterSheetContent}
141
+ >
142
+ {PRESETS.map(option => {
143
+ const selected = option.value === preset;
144
+ return (
145
+ <Pressable
146
+ key={option.value}
147
+ style={({ pressed }) => [ui.sheetOption, pressed && ui.listItemPressed]}
148
+ onPress={() => applyPreset(option.value)}
149
+ >
150
+ <Text style={[ui.sheetOptionLabel, selected && ui.sheetOptionLabelSelected]}>{option.label}</Text>
151
+ {selected ? <Icon name="check" size={16} color={theme.colors.primary} /> : null}
152
+ </Pressable>
153
+ );
154
+ })}
155
+
156
+ {hasScope ? (
157
+ <>
158
+ <Text style={[ui.sectionLabel, ui.dateRangeSheetSection]}>{scopeTitle}</Text>
159
+ {scopeOptions!.map(option => {
160
+ const selected = option.value === scopeValue;
161
+ return (
162
+ <Pressable
163
+ key={option.value}
164
+ style={({ pressed }) => [ui.sheetOption, pressed && ui.listItemPressed]}
165
+ onPress={() => {
166
+ onScopeChange!(option.value);
167
+ setPresetOpen(false);
168
+ }}
169
+ >
170
+ <Text style={[ui.sheetOptionLabel, selected && ui.sheetOptionLabelSelected]}>{option.label}</Text>
171
+ {selected ? <Icon name="check" size={16} color={theme.colors.primary} /> : null}
172
+ </Pressable>
173
+ );
174
+ })}
175
+ </>
176
+ ) : null}
177
+ </BottomSheet>
178
+
179
+ <BottomSheet
180
+ visible={customOpen}
181
+ onClose={() => {
182
+ setCustomOpen(false);
183
+ setPicker(null);
184
+ }}
185
+ title="Rango personalizado"
186
+ minHeight={280}
187
+ contentStyle={ui.filterSheetContent}
188
+ >
189
+ <Pressable style={({ pressed }) => [ui.sheetOption, pressed && ui.listItemPressed]} onPress={() => setPicker('from')}>
190
+ <View>
191
+ <Text style={ui.dateRangeSheetCaption}>Desde</Text>
192
+ <Text style={ui.sheetOptionLabel}>{formatDisplay(value.from, locale)}</Text>
193
+ </View>
194
+ <Icon name="pencil" size={16} color={theme.colors.textMuted} />
195
+ </Pressable>
196
+ <Pressable style={({ pressed }) => [ui.sheetOption, pressed && ui.listItemPressed]} onPress={() => setPicker('to')}>
197
+ <View>
198
+ <Text style={ui.dateRangeSheetCaption}>Hasta</Text>
199
+ <Text style={ui.sheetOptionLabel}>{formatDisplay(value.to, locale)}</Text>
200
+ </View>
201
+ <Icon name="pencil" size={16} color={theme.colors.textMuted} />
202
+ </Pressable>
203
+
204
+ {picker ? (
205
+ <DateTimePicker
206
+ value={parse(picker === 'from' ? value.from : value.to)}
207
+ mode="date"
208
+ display={Platform.OS === 'android' ? 'default' : 'compact'}
209
+ onChange={(_, date) => {
210
+ if (Platform.OS === 'android') setPicker(null);
211
+ if (!date) return;
212
+ const next = iso(date);
213
+ onChange(
214
+ picker === 'from'
215
+ ? { from: next, to: next > value.to ? next : value.to }
216
+ : { from: next < value.from ? next : value.from, to: next },
217
+ );
218
+ }}
219
+ />
220
+ ) : null}
221
+ </BottomSheet>
222
+ </View>
223
+ );
224
+ }
225
+
226
+ export const defaultDateRange = (): DateRangeValue => ({ from: monthStart(), to: today() });
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { ObiText } from '../typography/ObiText';
5
+ import { Icon, type IconName } from './Icon';
6
+ import { Button } from './Button';
7
+
8
+ export function EmptyState({
9
+ title,
10
+ description,
11
+ icon = 'files',
12
+ actionLabel,
13
+ onAction,
14
+ }: {
15
+ title: string;
16
+ description?: string;
17
+ icon?: IconName;
18
+ actionLabel?: string;
19
+ onAction?: () => void;
20
+ }) {
21
+ const theme = useObiTheme();
22
+ return (
23
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: theme.spacing.xxl, gap: theme.spacing.md }}>
24
+ <View style={{ width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.glass.primaryTint }}>
25
+ <Icon name={icon} size={24} color={theme.colors.primary} />
26
+ </View>
27
+ <ObiText variant="subtitle" weight="800" style={{ textAlign: 'center' }}>{title}</ObiText>
28
+ {description ? <ObiText muted style={{ textAlign: 'center' }}>{description}</ObiText> : null}
29
+ {actionLabel && onAction ? <Button title={actionLabel} onPress={onAction} variant="outline" size="sm" /> : null}
30
+ </View>
31
+ );
32
+ }
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { ObiText } from '../typography/ObiText';
5
+ import { Icon } from './Icon';
6
+ import { Button } from './Button';
7
+
8
+ export type ErrorStateProps = {
9
+ title?: string;
10
+ message?: string;
11
+ retryLabel?: string;
12
+ onRetry?: () => void;
13
+ style?: StyleProp<ViewStyle>;
14
+ };
15
+
16
+ export function ErrorState({
17
+ title = 'Algo salió mal',
18
+ message,
19
+ retryLabel = 'Reintentar',
20
+ onRetry,
21
+ style,
22
+ }: ErrorStateProps) {
23
+ const theme = useObiTheme();
24
+ return (
25
+ <View
26
+ style={[
27
+ {
28
+ flex: 1,
29
+ alignItems: 'center',
30
+ justifyContent: 'center',
31
+ padding: theme.spacing.xxl,
32
+ gap: theme.spacing.md,
33
+ },
34
+ style,
35
+ ]}
36
+ >
37
+ <View
38
+ style={{
39
+ width: 52,
40
+ height: 52,
41
+ borderRadius: 26,
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ backgroundColor: theme.colors.danger + '18',
45
+ }}
46
+ >
47
+ <Icon name="ban" size={24} color={theme.colors.danger} />
48
+ </View>
49
+ <ObiText variant="subtitle" weight="800" style={{ textAlign: 'center' }}>
50
+ {title}
51
+ </ObiText>
52
+ {message ? (
53
+ <ObiText muted style={{ textAlign: 'center', maxWidth: 420 }}>
54
+ {message}
55
+ </ObiText>
56
+ ) : null}
57
+ {onRetry ? (
58
+ <Button title={retryLabel} variant="outline" size="sm" onPress={onRetry} />
59
+ ) : null}
60
+ </View>
61
+ );
62
+ }
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import { Pressable, Text } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useObiTheme } from '../provider/ObiUIProvider';
5
+ import { shadow } from '../theme/effects';
6
+
7
+ export function Fab({ label, onPress }: { label: string; onPress: () => void }) {
8
+ const insets = useSafeAreaInsets();
9
+ const theme = useObiTheme();
10
+ const bottom = theme.navigation.bottomBarHeight + theme.spacing.sm + insets.bottom + theme.spacing.sm;
11
+ return (
12
+ <Pressable
13
+ accessibilityRole="button"
14
+ onPress={onPress}
15
+ hitSlop={8}
16
+ style={({ pressed }) => ({
17
+ position: 'absolute',
18
+ bottom,
19
+ right: theme.spacing.lg,
20
+ flexDirection: 'row',
21
+ alignItems: 'center',
22
+ gap: theme.spacing.sm,
23
+ backgroundColor: theme.colors.primary,
24
+ borderRadius: theme.radius.pill,
25
+ paddingVertical: theme.spacing.md,
26
+ paddingHorizontal: theme.spacing.xl,
27
+ zIndex: 50,
28
+ opacity: pressed ? 0.76 : 1,
29
+ ...shadow.floating,
30
+ })}
31
+ >
32
+ <Text style={{ color: theme.colors.onPrimary, fontSize: 20, fontWeight: '800', lineHeight: 22 }}>+</Text>
33
+ <Text style={{ color: theme.colors.onPrimary, fontSize: theme.type.body, fontWeight: '700' }}>{label}</Text>
34
+ </Pressable>
35
+ );
36
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+
5
+ export type FormRowProps = {
6
+ children: React.ReactNode;
7
+ style?: StyleProp<ViewStyle>;
8
+ };
9
+
10
+ /**
11
+ * Lays 1–N form fields in a responsive row with theme gaps.
12
+ * Each child should typically be wrapped or use flex:1 via FormRow.Item.
13
+ */
14
+ export function FormRow({ children, style }: FormRowProps) {
15
+ const theme = useObiTheme();
16
+ return (
17
+ <View style={[{ flexDirection: 'row', gap: theme.spacing.md, alignItems: 'flex-start' }, style]}>
18
+ {React.Children.map(children, child => {
19
+ if (!React.isValidElement(child)) return child;
20
+ if (child.type === FormRowItem) return child;
21
+ return <FormRowItem>{child}</FormRowItem>;
22
+ })}
23
+ </View>
24
+ );
25
+ }
26
+
27
+ function FormRowItem({
28
+ children,
29
+ style,
30
+ flex = 1,
31
+ }: {
32
+ children: React.ReactNode;
33
+ style?: StyleProp<ViewStyle>;
34
+ flex?: number;
35
+ }) {
36
+ return <View style={[{ flex, minWidth: 0 }, style]}>{children}</View>;
37
+ }
38
+
39
+ FormRow.Item = FormRowItem;
@@ -0,0 +1,126 @@
1
+ import React from 'react';
2
+ import { Keyboard, ScrollView, View, type StyleProp, type ViewStyle } 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 { GlassSurface } from '../glass/GlassSurface';
8
+
9
+ export type FormWorkspaceShellProps = {
10
+ children: React.ReactNode;
11
+ title?: string;
12
+ subtitle?: React.ReactNode;
13
+ contentStyle?: StyleProp<ViewStyle>;
14
+ secondaryTitle?: string;
15
+ onSecondary?: () => void;
16
+ secondaryDisabled?: boolean;
17
+ primaryTitle: string;
18
+ onPrimary: () => void;
19
+ primaryDisabled?: boolean;
20
+ primaryLoading?: boolean;
21
+ };
22
+
23
+ export function FormWorkspaceShell({
24
+ children,
25
+ title,
26
+ subtitle,
27
+ contentStyle,
28
+ secondaryTitle,
29
+ onSecondary,
30
+ secondaryDisabled = false,
31
+ primaryTitle,
32
+ onPrimary,
33
+ primaryDisabled = false,
34
+ primaryLoading = false,
35
+ }: FormWorkspaceShellProps) {
36
+ const theme = useObiTheme();
37
+ return (
38
+ <Screen style={{ backgroundColor: 'transparent' }} navigationClearance={false}>
39
+ <ScrollView
40
+ style={{ flex: 1 }}
41
+ contentContainerStyle={[
42
+ {
43
+ padding: theme.spacing.lg,
44
+ gap: theme.spacing.lg,
45
+ maxWidth: 840,
46
+ width: '100%',
47
+ alignSelf: 'center',
48
+ },
49
+ contentStyle,
50
+ ]}
51
+ keyboardShouldPersistTaps="handled"
52
+ keyboardDismissMode="on-drag"
53
+ >
54
+ {title ? (
55
+ <View style={{ gap: theme.spacing.md }}>
56
+ <ObiText variant="title" weight="900">{title}</ObiText>
57
+ {subtitle ? (typeof subtitle === 'string' ? <ObiText muted>{subtitle}</ObiText> : subtitle) : null}
58
+ </View>
59
+ ) : null}
60
+ {children}
61
+ </ScrollView>
62
+
63
+ <GlassSurface
64
+ variant="dock"
65
+ style={{
66
+ flexDirection: 'row',
67
+ alignItems: 'stretch',
68
+ width: '100%',
69
+ gap: theme.spacing.sm,
70
+ paddingHorizontal: theme.spacing.sm,
71
+ paddingTop: theme.spacing.sm,
72
+ paddingBottom: theme.spacing.md,
73
+ backgroundColor: 'transparent',
74
+ zIndex: 20,
75
+ borderTopWidth: 1,
76
+ borderTopColor: theme.glass.borderSoft,
77
+ }}
78
+ >
79
+ <View style={{ flexDirection: 'row', width: '100%', gap: theme.spacing.sm }} collapsable={false}>
80
+ {onSecondary ? (
81
+ <Button
82
+ title={secondaryTitle ?? 'CANCELAR'}
83
+ variant="danger"
84
+ disabled={secondaryDisabled}
85
+ onPress={() => {
86
+ Keyboard.dismiss();
87
+ onSecondary();
88
+ }}
89
+ style={{ flex: 1, flexBasis: 0, minHeight: 52, alignSelf: 'stretch' }}
90
+ />
91
+ ) : null}
92
+ <Button
93
+ title={primaryTitle}
94
+ loading={primaryLoading}
95
+ disabled={primaryDisabled || primaryLoading}
96
+ onPress={() => {
97
+ Keyboard.dismiss();
98
+ onPrimary();
99
+ }}
100
+ style={{ flex: 1, flexBasis: 0, minHeight: 52, alignSelf: 'stretch' }}
101
+ />
102
+ </View>
103
+ </GlassSurface>
104
+ </Screen>
105
+ );
106
+ }
107
+
108
+ export type FormBlockProps = {
109
+ title: string;
110
+ hint?: string;
111
+ children: React.ReactNode;
112
+ style?: StyleProp<ViewStyle>;
113
+ };
114
+
115
+ export function FormBlock({ title, hint, children, style }: FormBlockProps) {
116
+ const theme = useObiTheme();
117
+ return (
118
+ <View style={[{ gap: theme.spacing.md }, style]}>
119
+ <View style={{ gap: 2 }}>
120
+ <ObiText variant="subtitle" weight="900">{title}</ObiText>
121
+ {hint ? <ObiText variant="caption" muted>{hint}</ObiText> : null}
122
+ </View>
123
+ {children}
124
+ </View>
125
+ );
126
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { Platform, StyleSheet, View } from 'react-native';
3
+ import { BlurView } from '@sbaiahmed1/react-native-blur';
4
+ import { useObiTheme } from '../provider/ObiUIProvider';
5
+
6
+ export function GlassTabBarBackground() {
7
+ const theme = useObiTheme();
8
+ const frosted = theme.isDark ? 'rgba(18,22,32,0.70)' : 'rgba(248,250,255,0.62)';
9
+ const overlay = theme.isDark ? 'rgba(18,22,32,0.18)' : 'rgba(255,255,255,0.10)';
10
+ return (
11
+ <View style={StyleSheet.absoluteFillObject} pointerEvents="none">
12
+ <View style={[StyleSheet.absoluteFillObject, { backgroundColor: frosted }]} />
13
+ <BlurView
14
+ blurType={theme.isDark ? 'dark' : Platform.OS === 'ios' ? 'systemMaterialLight' : 'light'}
15
+ blurAmount={Platform.OS === 'ios' ? 36 : 28}
16
+ blurRounds={4}
17
+ overlayColor={overlay}
18
+ reducedTransparencyFallbackColor={frosted}
19
+ style={StyleSheet.absoluteFillObject}
20
+ />
21
+ <View
22
+ pointerEvents="none"
23
+ style={[
24
+ StyleSheet.absoluteFillObject,
25
+ { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: theme.glass.borderSoft },
26
+ ]}
27
+ />
28
+ </View>
29
+ );
30
+ }
31
+
32
+ export const glassTabBarBaseStyle = {
33
+ position: 'absolute' as const,
34
+ backgroundColor: 'transparent' as const,
35
+ borderTopWidth: 0,
36
+ elevation: 0,
37
+ shadowOpacity: 0,
38
+ zIndex: 100,
39
+ };