@klyftig/mobile-ui 1.0.5 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klyftig/mobile-ui",
3
- "version": "1.0.5",
3
+ "version": "1.2.0",
4
4
  "scripts": {
5
5
  "pack-install": "npm pack && mv klyftig-ui-*.tgz /tmp/klyftig-ui-local.tgz && cd ../../mobile && npm install /tmp/klyftig-ui-local.tgz"
6
6
  },
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+ import { useTheme } from '../hooks/useTheme';
5
+
6
+ /**
7
+ * Intent of an empty state:
8
+ * - `empty` — no content yet, but everything is wired up (default).
9
+ * - `pending` — feature is set up but waiting on data / connection / opt-in.
10
+ * Use this when the screen will fill in once the user takes
11
+ * an external step (e.g. connect Health, enable notifications).
12
+ * - `error` — something is broken; user may need to retry.
13
+ */
14
+ export type EmptyStateVariant = 'empty' | 'pending' | 'error';
15
+
16
+ export interface EmptyStateProps {
17
+ /** MaterialCommunityIcons name. Defaults vary by variant. */
18
+ icon?: string;
19
+ iconSize?: number;
20
+ iconColor?: string;
21
+ title: string;
22
+ description?: string;
23
+ /** Render an action button (or any node) below the description. */
24
+ action?: React.ReactNode;
25
+ variant?: EmptyStateVariant;
26
+ style?: StyleProp<ViewStyle>;
27
+ }
28
+
29
+ const VARIANT_DEFAULTS: Record<EmptyStateVariant, { icon: string; tint: string }> = {
30
+ empty: { icon: 'inbox-outline', tint: '#9B9B9B' },
31
+ pending: { icon: 'progress-clock', tint: '#2196F3' },
32
+ error: { icon: 'alert-circle-outline', tint: '#F44336' },
33
+ };
34
+
35
+ /**
36
+ * Large-icon empty state used on list/feed screens with no data.
37
+ * Centered in its parent; pass a flex-1 wrapper to vertically center on a full screen.
38
+ */
39
+ export function EmptyState({
40
+ icon,
41
+ iconSize = 64,
42
+ iconColor,
43
+ title,
44
+ description,
45
+ action,
46
+ variant = 'empty',
47
+ style,
48
+ }: EmptyStateProps) {
49
+ const { colors } = useTheme();
50
+ const variantDefaults = VARIANT_DEFAULTS[variant];
51
+ const resolvedIcon = icon ?? variantDefaults.icon;
52
+ const resolvedIconColor =
53
+ iconColor ?? (variant === 'empty' ? colors.icon.tertiary : variantDefaults.tint);
54
+
55
+ return (
56
+ <View style={[styles.container, style]}>
57
+ <Icon name={resolvedIcon as any} size={iconSize} color={resolvedIconColor} />
58
+ <Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
59
+ {description && (
60
+ <Text style={[styles.description, { color: colors.text.tertiary }]}>
61
+ {description}
62
+ </Text>
63
+ )}
64
+ {action && <View style={styles.action}>{action}</View>}
65
+ </View>
66
+ );
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ container: {
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ padding: 40,
74
+ },
75
+ title: {
76
+ fontSize: 18,
77
+ fontWeight: '700',
78
+ marginTop: 16,
79
+ marginBottom: 8,
80
+ textAlign: 'center',
81
+ },
82
+ description: {
83
+ fontSize: 14,
84
+ fontWeight: '500',
85
+ textAlign: 'center',
86
+ },
87
+ action: {
88
+ marginTop: 20,
89
+ },
90
+ });
@@ -0,0 +1,81 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ StyleProp,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import { useTheme } from '../hooks/useTheme';
11
+
12
+ export interface ErrorStateProps {
13
+ title?: string;
14
+ description?: string;
15
+ retryLabel?: string;
16
+ onRetry?: () => void;
17
+ style?: StyleProp<ViewStyle>;
18
+ }
19
+
20
+ /**
21
+ * Centered error view with optional retry button.
22
+ * Use inside a flex-1 wrapper to vertically center on a full screen.
23
+ */
24
+ export function ErrorState({
25
+ title = 'Something went wrong',
26
+ description,
27
+ retryLabel = 'Try Again',
28
+ onRetry,
29
+ style,
30
+ }: ErrorStateProps) {
31
+ const { colors } = useTheme();
32
+ return (
33
+ <View style={[styles.container, style]}>
34
+ <Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
35
+ {description && (
36
+ <Text style={[styles.description, { color: colors.text.secondary }]}>
37
+ {description}
38
+ </Text>
39
+ )}
40
+ {onRetry && (
41
+ <TouchableOpacity
42
+ onPress={onRetry}
43
+ accessibilityRole="button"
44
+ accessibilityLabel={retryLabel}
45
+ style={[styles.retryButton, { backgroundColor: colors.text.primary }]}
46
+ >
47
+ <Text style={styles.retryText}>{retryLabel}</Text>
48
+ </TouchableOpacity>
49
+ )}
50
+ </View>
51
+ );
52
+ }
53
+
54
+ const styles = StyleSheet.create({
55
+ container: {
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ padding: 40,
59
+ },
60
+ title: {
61
+ fontSize: 18,
62
+ fontWeight: '700',
63
+ marginBottom: 8,
64
+ textAlign: 'center',
65
+ },
66
+ description: {
67
+ fontSize: 14,
68
+ textAlign: 'center',
69
+ marginBottom: 20,
70
+ },
71
+ retryButton: {
72
+ paddingHorizontal: 24,
73
+ paddingVertical: 10,
74
+ borderRadius: 10,
75
+ },
76
+ retryText: {
77
+ color: '#FFFFFF',
78
+ fontWeight: '600',
79
+ fontSize: 14,
80
+ },
81
+ });
@@ -19,6 +19,37 @@ interface FABAction {
19
19
  color?: string;
20
20
  }
21
21
 
22
+ /**
23
+ * Floating action button with two operating modes:
24
+ *
25
+ * 1. **Toggle-menu (default).** With just `actions`, tapping the FAB animates a
26
+ * vertical column of sub-buttons up from the FAB, each with a label pill.
27
+ * Tap an action to dismiss the menu and run it.
28
+ *
29
+ * 2. **Single-action override.** Pass `overrideIcon` and `overrideOnPress` to
30
+ * swap the FAB into a one-tap shortcut. Used by the chat dock: when the
31
+ * chat input gains focus, the host swaps the FAB icon to `"camera"` and
32
+ * `overrideOnPress` opens the camera directly — bypassing the menu.
33
+ * `actions` are ignored in this mode.
34
+ *
35
+ * @example
36
+ * // Toggle-menu mode
37
+ * <FAB
38
+ * actions={[
39
+ * { icon: 'camera', label: 'Snap food', onPress: openCamera },
40
+ * { icon: 'silverware-fork-knife', label: 'Suggest recipe', onPress: openRecipes },
41
+ * { icon: 'message-text', label: 'Chat', onPress: openChat },
42
+ * ]}
43
+ * />
44
+ *
45
+ * @example
46
+ * // Single-action override (chat input focused)
47
+ * <FAB
48
+ * actions={[]}
49
+ * overrideIcon="camera"
50
+ * overrideOnPress={openCamera}
51
+ * />
52
+ */
22
53
  interface FABProps {
23
54
  actions: FABAction[];
24
55
  /** Override main button icon (e.g. 'camera' when input is focused) */
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import { View, Text, ActivityIndicator, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
+ import { useTheme } from '../hooks/useTheme';
4
+
5
+ export interface LoadingStateProps {
6
+ message?: string;
7
+ size?: 'small' | 'large';
8
+ color?: string;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /**
13
+ * Centered ActivityIndicator + optional message. Default uses the brand text color.
14
+ */
15
+ export function LoadingState({
16
+ message,
17
+ size = 'large',
18
+ color,
19
+ style,
20
+ }: LoadingStateProps) {
21
+ const { colors } = useTheme();
22
+ const resolvedColor = color ?? colors.text.primary;
23
+ return (
24
+ <View style={[styles.container, style]}>
25
+ <ActivityIndicator size={size} color={resolvedColor} />
26
+ {message && (
27
+ <Text style={[styles.message, { color: colors.text.secondary }]}>{message}</Text>
28
+ )}
29
+ </View>
30
+ );
31
+ }
32
+
33
+ const styles = StyleSheet.create({
34
+ container: {
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ padding: 40,
38
+ },
39
+ message: {
40
+ fontSize: 14,
41
+ fontWeight: '500',
42
+ marginTop: 12,
43
+ textAlign: 'center',
44
+ },
45
+ });
@@ -0,0 +1,227 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ TouchableOpacity,
7
+ StyleProp,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
11
+ import { useTheme } from '../hooks/useTheme';
12
+ import { CircularProgress } from './progress/CircularProgress';
13
+
14
+ export interface ProgressCardProps {
15
+ /** Bottom label, e.g. "Protein". */
16
+ label: string;
17
+ /** Primary value text (already formatted), e.g. "151.8" or "1428". */
18
+ value: string;
19
+ /** Optional unit appended to value, e.g. "g" or "cal". */
20
+ unit?: string;
21
+ /** MaterialCommunityIcons name shown above the value (compact mode only). */
22
+ icon?: string;
23
+ /** Optional emoji shown next to label (e.g. "🍗" for protein). */
24
+ emoji?: string;
25
+ /** Current progress numerator. If omitted, no ring is rendered. */
26
+ current?: number;
27
+ /** Progress denominator. */
28
+ target?: number;
29
+ /** Ring stroke color (defaults to text.primary). */
30
+ color?: string;
31
+ /**
32
+ * `compact` — 3-up macro tile (Home today). Stacked: ring on top, label below.
33
+ * `wide` — 2-up daily-goal tile (Profile). Ring on left, text on right.
34
+ */
35
+ size?: 'compact' | 'wide';
36
+ onPress?: () => void;
37
+ style?: StyleProp<ViewStyle>;
38
+ }
39
+
40
+ /**
41
+ * Stat tile with optional progress ring. Use `size="compact"` for the row of
42
+ * macro tiles on Home, `size="wide"` for the daily-goals 2x2 grid on Profile.
43
+ */
44
+ export const ProgressCard: React.FC<ProgressCardProps> = ({
45
+ label,
46
+ value,
47
+ unit,
48
+ icon,
49
+ emoji,
50
+ current,
51
+ target,
52
+ color,
53
+ size = 'compact',
54
+ onPress,
55
+ style,
56
+ }) => {
57
+ const { colors } = useTheme();
58
+ const showRing = typeof current === 'number' && typeof target === 'number' && target > 0;
59
+ const ringColor = color ?? colors.text.primary;
60
+
61
+ const Wrapper: any = onPress ? TouchableOpacity : View;
62
+ const wrapperProps = onPress
63
+ ? { onPress, activeOpacity: 0.85, accessibilityRole: 'button' as const }
64
+ : {};
65
+
66
+ if (size === 'wide') {
67
+ return (
68
+ <Wrapper
69
+ {...wrapperProps}
70
+ style={[
71
+ styles.card,
72
+ styles.wideCard,
73
+ { backgroundColor: colors.background.card, borderColor: colors.border.light },
74
+ style,
75
+ ]}
76
+ >
77
+ {showRing ? (
78
+ <CircularProgress
79
+ size={48}
80
+ strokeWidth={5}
81
+ current={current!}
82
+ target={target!}
83
+ color={ringColor}
84
+ hideText
85
+ />
86
+ ) : icon ? (
87
+ <View style={[styles.iconBubble, { backgroundColor: colors.background.secondary }]}>
88
+ <Icon name={icon as any} size={22} color={ringColor} />
89
+ </View>
90
+ ) : null}
91
+ <View style={styles.wideText}>
92
+ <Text style={[styles.wideLabel, { color: colors.text.tertiary }]} numberOfLines={1}>
93
+ {emoji ? `${emoji} ` : ''}
94
+ {label}
95
+ </Text>
96
+ <Text style={[styles.wideValue, { color: colors.text.primary }]} numberOfLines={1}>
97
+ {value}
98
+ {unit ? <Text style={styles.wideUnit}> {unit}</Text> : null}
99
+ </Text>
100
+ </View>
101
+ </Wrapper>
102
+ );
103
+ }
104
+
105
+ // compact
106
+ return (
107
+ <Wrapper
108
+ {...wrapperProps}
109
+ style={[
110
+ styles.card,
111
+ styles.compactCard,
112
+ { backgroundColor: colors.background.card, borderColor: colors.border.light },
113
+ style,
114
+ ]}
115
+ >
116
+ <View style={styles.compactRingWrap}>
117
+ {showRing ? (
118
+ <CircularProgress
119
+ size={56}
120
+ strokeWidth={6}
121
+ current={current!}
122
+ target={target!}
123
+ color={ringColor}
124
+ hideText
125
+ />
126
+ ) : (
127
+ <View style={[styles.iconBubble, { backgroundColor: colors.background.secondary }]}>
128
+ {icon ? <Icon name={icon as any} size={22} color={ringColor} /> : null}
129
+ </View>
130
+ )}
131
+ {emoji ? (
132
+ <View style={styles.compactEmojiWrap}>
133
+ <Text style={styles.compactEmoji}>{emoji}</Text>
134
+ </View>
135
+ ) : null}
136
+ </View>
137
+ <Text style={[styles.compactValue, { color: colors.text.primary }]} numberOfLines={1}>
138
+ {value}
139
+ {unit ? <Text style={styles.compactUnit}>{unit}</Text> : null}
140
+ </Text>
141
+ <Text style={[styles.compactLabel, { color: colors.text.tertiary }]} numberOfLines={1}>
142
+ {label}
143
+ </Text>
144
+ </Wrapper>
145
+ );
146
+ };
147
+
148
+ const styles = StyleSheet.create({
149
+ card: {
150
+ borderRadius: 20,
151
+ borderWidth: 1,
152
+ shadowColor: '#000',
153
+ shadowOffset: { width: 0, height: 2 },
154
+ shadowOpacity: 0.06,
155
+ shadowRadius: 8,
156
+ elevation: 3,
157
+ },
158
+ compactCard: {
159
+ flex: 1,
160
+ paddingVertical: 16,
161
+ paddingHorizontal: 12,
162
+ alignItems: 'center',
163
+ justifyContent: 'center',
164
+ },
165
+ compactRingWrap: {
166
+ width: 56,
167
+ height: 56,
168
+ alignItems: 'center',
169
+ justifyContent: 'center',
170
+ marginBottom: 10,
171
+ },
172
+ compactEmojiWrap: {
173
+ position: 'absolute',
174
+ alignItems: 'center',
175
+ justifyContent: 'center',
176
+ },
177
+ compactEmoji: {
178
+ fontSize: 22,
179
+ },
180
+ compactValue: {
181
+ fontSize: 18,
182
+ fontWeight: '800',
183
+ },
184
+ compactUnit: {
185
+ fontSize: 12,
186
+ fontWeight: '600',
187
+ },
188
+ compactLabel: {
189
+ fontSize: 12,
190
+ fontWeight: '600',
191
+ marginTop: 2,
192
+ textTransform: 'uppercase',
193
+ letterSpacing: 0.4,
194
+ },
195
+ wideCard: {
196
+ flexDirection: 'row',
197
+ alignItems: 'center',
198
+ paddingVertical: 16,
199
+ paddingHorizontal: 16,
200
+ gap: 14,
201
+ },
202
+ iconBubble: {
203
+ width: 48,
204
+ height: 48,
205
+ borderRadius: 24,
206
+ alignItems: 'center',
207
+ justifyContent: 'center',
208
+ },
209
+ wideText: {
210
+ flex: 1,
211
+ },
212
+ wideLabel: {
213
+ fontSize: 12,
214
+ fontWeight: '600',
215
+ textTransform: 'uppercase',
216
+ letterSpacing: 0.4,
217
+ marginBottom: 4,
218
+ },
219
+ wideValue: {
220
+ fontSize: 22,
221
+ fontWeight: '800',
222
+ },
223
+ wideUnit: {
224
+ fontSize: 14,
225
+ fontWeight: '600',
226
+ },
227
+ });
@@ -0,0 +1,190 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import {
3
+ TouchableOpacity,
4
+ View,
5
+ Text,
6
+ StyleSheet,
7
+ StyleProp,
8
+ ViewStyle,
9
+ TextStyle,
10
+ } from 'react-native';
11
+ import { useTheme } from '../hooks/useTheme';
12
+
13
+ type RadioGroupContextValue<T> = {
14
+ value: T | undefined;
15
+ onChange: (value: T) => void;
16
+ };
17
+
18
+ const RadioGroupContext = createContext<RadioGroupContextValue<unknown> | null>(null);
19
+
20
+ export interface RadioGroupProps<T> {
21
+ value: T | undefined;
22
+ onChange: (value: T) => void;
23
+ children: React.ReactNode;
24
+ style?: StyleProp<ViewStyle>;
25
+ accessibilityLabel?: string;
26
+ }
27
+
28
+ export function RadioGroup<T>({ value, onChange, children, style, accessibilityLabel }: RadioGroupProps<T>) {
29
+ return (
30
+ <RadioGroupContext.Provider value={{ value, onChange: onChange as (v: unknown) => void }}>
31
+ <View
32
+ accessibilityRole="radiogroup"
33
+ accessibilityLabel={accessibilityLabel}
34
+ style={style}
35
+ >
36
+ {children}
37
+ </View>
38
+ </RadioGroupContext.Provider>
39
+ );
40
+ }
41
+
42
+ export interface RadioOptionProps<T = string> {
43
+ /** The value this option represents — used for selection matching when inside a RadioGroup. */
44
+ value?: T;
45
+ /** Explicit selected flag. Ignored if rendered inside a RadioGroup with a matching `value`. */
46
+ selected?: boolean;
47
+ /** Callback when the option is tapped. Ignored if rendered inside a RadioGroup (the group's onChange fires). */
48
+ onPress?: () => void;
49
+ /** Primary label text. Either `label` or `children` is required. */
50
+ label?: string;
51
+ /** Secondary description text shown under the label. */
52
+ description?: string;
53
+ /** Render custom content to the right of the radio circle instead of label/description. */
54
+ children?: React.ReactNode;
55
+ /** `"sm"` for a compact inline row (gender pickers); `"md"` for a card-style row (activity level). */
56
+ variant?: 'sm' | 'md';
57
+ style?: StyleProp<ViewStyle>;
58
+ labelStyle?: StyleProp<TextStyle>;
59
+ disabled?: boolean;
60
+ }
61
+
62
+ /**
63
+ * A tap-anywhere radio row: the circle is purely visual (pointerEvents: none)
64
+ * and the entire row is a single TouchableOpacity. Safe inside ScrollViews
65
+ * and dialogs — no competing gesture responders.
66
+ *
67
+ * Use inside <RadioGroup value={x} onChange={setX}> for controlled selection,
68
+ * or pass `selected` + `onPress` directly for standalone use.
69
+ */
70
+ export function RadioOption<T = string>({
71
+ value,
72
+ selected,
73
+ onPress,
74
+ label,
75
+ description,
76
+ children,
77
+ variant = 'sm',
78
+ style,
79
+ labelStyle,
80
+ disabled = false,
81
+ }: RadioOptionProps<T>) {
82
+ const { colors } = useTheme();
83
+ const group = useContext(RadioGroupContext) as RadioGroupContextValue<T> | null;
84
+
85
+ const isSelected = group && value !== undefined ? group.value === value : !!selected;
86
+ const handlePress = () => {
87
+ if (disabled) return;
88
+ if (group && value !== undefined) {
89
+ group.onChange(value);
90
+ } else {
91
+ onPress?.();
92
+ }
93
+ };
94
+
95
+ const isCard = variant === 'md';
96
+ const ringColor = isSelected ? colors.text.primary : colors.text.tertiary;
97
+
98
+ return (
99
+ <TouchableOpacity
100
+ onPress={handlePress}
101
+ disabled={disabled}
102
+ activeOpacity={0.7}
103
+ accessibilityRole="radio"
104
+ accessibilityState={{ selected: isSelected, disabled }}
105
+ accessibilityLabel={label}
106
+ style={[
107
+ isCard ? styles.cardRow : styles.inlineRow,
108
+ disabled && styles.disabled,
109
+ style,
110
+ ]}
111
+ >
112
+ {/* Radio circle — fully non-interactive; outer TouchableOpacity handles taps. */}
113
+ <View pointerEvents="none" style={[styles.ring, { borderColor: ringColor }]}>
114
+ {isSelected && <View style={[styles.dot, { backgroundColor: colors.text.primary }]} />}
115
+ </View>
116
+
117
+ {children ?? (
118
+ <View style={styles.textContainer}>
119
+ {label && (
120
+ <Text
121
+ style={[
122
+ isCard ? styles.cardLabel : styles.inlineLabel,
123
+ { color: colors.text.primary },
124
+ labelStyle,
125
+ ]}
126
+ >
127
+ {label}
128
+ </Text>
129
+ )}
130
+ {description && (
131
+ <Text style={[styles.description, { color: colors.text.secondary }]}>
132
+ {description}
133
+ </Text>
134
+ )}
135
+ </View>
136
+ )}
137
+ </TouchableOpacity>
138
+ );
139
+ }
140
+
141
+ const styles = StyleSheet.create({
142
+ inlineRow: {
143
+ flexDirection: 'row',
144
+ alignItems: 'center',
145
+ paddingVertical: 6,
146
+ paddingHorizontal: 4,
147
+ },
148
+ cardRow: {
149
+ flexDirection: 'row',
150
+ alignItems: 'center',
151
+ backgroundColor: 'rgba(255,255,255,0.3)',
152
+ borderRadius: 12,
153
+ padding: 12,
154
+ borderWidth: 1,
155
+ borderColor: 'rgba(255,255,255,0.4)',
156
+ },
157
+ ring: {
158
+ width: 20,
159
+ height: 20,
160
+ borderRadius: 10,
161
+ borderWidth: 2,
162
+ alignItems: 'center',
163
+ justifyContent: 'center',
164
+ },
165
+ dot: {
166
+ width: 10,
167
+ height: 10,
168
+ borderRadius: 5,
169
+ },
170
+ textContainer: {
171
+ flex: 1,
172
+ marginLeft: 12,
173
+ },
174
+ inlineLabel: {
175
+ fontSize: 15,
176
+ fontWeight: '500',
177
+ },
178
+ cardLabel: {
179
+ fontSize: 15,
180
+ fontWeight: '600',
181
+ marginBottom: 2,
182
+ },
183
+ description: {
184
+ fontSize: 13,
185
+ fontWeight: '500',
186
+ },
187
+ disabled: {
188
+ opacity: 0.5,
189
+ },
190
+ });
@@ -0,0 +1,261 @@
1
+ import React, { useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ Image,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ Animated,
9
+ ImageSourcePropType,
10
+ StyleProp,
11
+ ViewStyle,
12
+ } from 'react-native';
13
+ import { LinearGradient } from 'expo-linear-gradient';
14
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
15
+ import { useTheme } from '../hooks/useTheme';
16
+
17
+ export interface RecipeRowProps {
18
+ title: string;
19
+ /** Short tag rendered under the title, e.g. "Lunch · 25 min". */
20
+ tag?: string;
21
+ /** Image source. Pass a string URL or a require(...) source. */
22
+ thumbnail?: ImageSourcePropType | string;
23
+ /** Emoji rendered on a colored gradient when no thumbnail is provided. */
24
+ fallbackEmoji?: string;
25
+ kcal: number;
26
+ protein: number;
27
+ carbs: number;
28
+ fat: number;
29
+ /** Render a "BEST" pill next to the title. */
30
+ bestMatch?: boolean;
31
+ /**
32
+ * `navigate` (default) — chevron-right; tapping the row fires onPress.
33
+ * `expand` — chevron-down/up; row expands inline to show `expandedContent`.
34
+ */
35
+ mode?: 'navigate' | 'expand';
36
+ expandedContent?: React.ReactNode;
37
+ onPress?: () => void;
38
+ style?: StyleProp<ViewStyle>;
39
+ }
40
+
41
+ const FALLBACK_GRADIENTS: Array<readonly [string, string]> = [
42
+ ['#FFE0B2', '#FFB74D'],
43
+ ['#C8E6C9', '#81C784'],
44
+ ['#BBDEFB', '#64B5F6'],
45
+ ['#F8BBD0', '#F06292'],
46
+ ['#D1C4E9', '#9575CD'],
47
+ ];
48
+
49
+ function pickGradient(seed: string): readonly [string, string] {
50
+ let h = 0;
51
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
52
+ return FALLBACK_GRADIENTS[h % FALLBACK_GRADIENTS.length];
53
+ }
54
+
55
+ /**
56
+ * One recipe in a list. Renders a thumbnail, title (+optional BEST pill),
57
+ * a tag line, and macros in a compact row.
58
+ */
59
+ export const RecipeRow: React.FC<RecipeRowProps> = ({
60
+ title,
61
+ tag,
62
+ thumbnail,
63
+ fallbackEmoji = '🍽️',
64
+ kcal,
65
+ protein,
66
+ carbs,
67
+ fat,
68
+ bestMatch,
69
+ mode = 'navigate',
70
+ expandedContent,
71
+ onPress,
72
+ style,
73
+ }) => {
74
+ const { colors } = useTheme();
75
+ const [expanded, setExpanded] = useState(false);
76
+ const rotation = React.useRef(new Animated.Value(0)).current;
77
+
78
+ const handlePress = () => {
79
+ if (mode === 'expand') {
80
+ const toValue = expanded ? 0 : 1;
81
+ Animated.timing(rotation, {
82
+ toValue,
83
+ duration: 180,
84
+ useNativeDriver: true,
85
+ }).start();
86
+ setExpanded(!expanded);
87
+ } else {
88
+ onPress?.();
89
+ }
90
+ };
91
+
92
+ const rotate = rotation.interpolate({
93
+ inputRange: [0, 1],
94
+ outputRange: ['0deg', '180deg'],
95
+ });
96
+
97
+ const chevronName = mode === 'expand' ? 'chevron-down' : 'chevron-right';
98
+ const gradient = pickGradient(title);
99
+
100
+ const thumbSrc =
101
+ typeof thumbnail === 'string' ? { uri: thumbnail } : thumbnail;
102
+
103
+ return (
104
+ <View
105
+ style={[
106
+ styles.card,
107
+ { backgroundColor: colors.background.card, borderColor: colors.border.light },
108
+ style,
109
+ ]}
110
+ >
111
+ <TouchableOpacity
112
+ activeOpacity={0.85}
113
+ onPress={handlePress}
114
+ accessibilityRole="button"
115
+ accessibilityLabel={title}
116
+ accessibilityState={mode === 'expand' ? { expanded } : undefined}
117
+ style={styles.row}
118
+ >
119
+ <View style={styles.thumb}>
120
+ {thumbSrc ? (
121
+ <Image source={thumbSrc} style={styles.thumbImage} />
122
+ ) : (
123
+ <LinearGradient colors={gradient} style={styles.thumbImage}>
124
+ <Text style={styles.thumbEmoji}>{fallbackEmoji}</Text>
125
+ </LinearGradient>
126
+ )}
127
+ </View>
128
+
129
+ <View style={styles.body}>
130
+ <View style={styles.titleRow}>
131
+ <Text style={[styles.title, { color: colors.text.primary }]} numberOfLines={1}>
132
+ {title}
133
+ </Text>
134
+ {bestMatch && (
135
+ <View style={[styles.bestPill, { backgroundColor: colors.text.primary }]}>
136
+ <Text style={styles.bestText}>BEST</Text>
137
+ </View>
138
+ )}
139
+ </View>
140
+ {tag ? (
141
+ <Text style={[styles.tag, { color: colors.text.tertiary }]} numberOfLines={1}>
142
+ {tag}
143
+ </Text>
144
+ ) : null}
145
+ <View style={styles.macros}>
146
+ <Macro label={`${Math.round(kcal)} cal`} color={colors.text.primary} />
147
+ <MacroDot color={colors.macro.protein} />
148
+ <Macro label={`${Math.round(protein)}g`} color={colors.text.secondary} />
149
+ <MacroDot color={colors.macro.carbs} />
150
+ <Macro label={`${Math.round(carbs)}g`} color={colors.text.secondary} />
151
+ <MacroDot color={colors.macro.fat} />
152
+ <Macro label={`${Math.round(fat)}g`} color={colors.text.secondary} />
153
+ </View>
154
+ </View>
155
+
156
+ <Animated.View style={{ transform: [{ rotate }] }}>
157
+ <Icon name={chevronName as any} size={22} color={colors.icon.tertiary} />
158
+ </Animated.View>
159
+ </TouchableOpacity>
160
+
161
+ {mode === 'expand' && expanded && expandedContent ? (
162
+ <View style={[styles.expanded, { borderTopColor: colors.border.light }]}>
163
+ {expandedContent}
164
+ </View>
165
+ ) : null}
166
+ </View>
167
+ );
168
+ };
169
+
170
+ const Macro: React.FC<{ label: string; color: string }> = ({ label, color }) => (
171
+ <Text style={[styles.macroText, { color }]}>{label}</Text>
172
+ );
173
+
174
+ const MacroDot: React.FC<{ color: string }> = ({ color }) => (
175
+ <View style={[styles.dot, { backgroundColor: color }]} />
176
+ );
177
+
178
+ const styles = StyleSheet.create({
179
+ card: {
180
+ borderRadius: 18,
181
+ borderWidth: 1,
182
+ overflow: 'hidden',
183
+ shadowColor: '#000',
184
+ shadowOffset: { width: 0, height: 2 },
185
+ shadowOpacity: 0.06,
186
+ shadowRadius: 8,
187
+ elevation: 2,
188
+ },
189
+ row: {
190
+ flexDirection: 'row',
191
+ alignItems: 'center',
192
+ padding: 12,
193
+ gap: 12,
194
+ },
195
+ thumb: {
196
+ width: 60,
197
+ height: 60,
198
+ borderRadius: 14,
199
+ overflow: 'hidden',
200
+ backgroundColor: '#F0EFEA',
201
+ },
202
+ thumbImage: {
203
+ width: '100%',
204
+ height: '100%',
205
+ alignItems: 'center',
206
+ justifyContent: 'center',
207
+ },
208
+ thumbEmoji: {
209
+ fontSize: 30,
210
+ },
211
+ body: {
212
+ flex: 1,
213
+ },
214
+ titleRow: {
215
+ flexDirection: 'row',
216
+ alignItems: 'center',
217
+ gap: 8,
218
+ },
219
+ title: {
220
+ fontSize: 15,
221
+ fontWeight: '700',
222
+ flexShrink: 1,
223
+ },
224
+ bestPill: {
225
+ paddingHorizontal: 6,
226
+ paddingVertical: 2,
227
+ borderRadius: 4,
228
+ },
229
+ bestText: {
230
+ color: '#FFFFFF',
231
+ fontSize: 10,
232
+ fontWeight: '800',
233
+ letterSpacing: 0.5,
234
+ },
235
+ tag: {
236
+ fontSize: 12,
237
+ fontWeight: '500',
238
+ marginTop: 2,
239
+ },
240
+ macros: {
241
+ flexDirection: 'row',
242
+ alignItems: 'center',
243
+ gap: 6,
244
+ marginTop: 6,
245
+ flexWrap: 'wrap',
246
+ },
247
+ macroText: {
248
+ fontSize: 12,
249
+ fontWeight: '600',
250
+ },
251
+ dot: {
252
+ width: 4,
253
+ height: 4,
254
+ borderRadius: 2,
255
+ },
256
+ expanded: {
257
+ paddingHorizontal: 12,
258
+ paddingVertical: 12,
259
+ borderTopWidth: 1,
260
+ },
261
+ });
@@ -0,0 +1,107 @@
1
+ import React from 'react';
2
+ import { View, Text, TouchableOpacity, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { useTheme } from '../hooks/useTheme';
6
+
7
+ export interface ScreenHeaderProps {
8
+ title: string;
9
+ subtitle?: string;
10
+ /** Left icon name (MaterialCommunityIcons). Defaults to "home-outline". */
11
+ leftIcon?: string;
12
+ /** Accessibility label for the left button. Defaults to "Go to Dashboard". */
13
+ leftAccessibilityLabel?: string;
14
+ onLeftPress?: () => void;
15
+ /** Right-side content (optional action, e.g. a settings button). Reserves space for symmetry when absent. */
16
+ right?: React.ReactNode;
17
+ /** Render below the title row — e.g. a tab bar or period toggle that should stay pinned with the header. */
18
+ below?: React.ReactNode;
19
+ style?: StyleProp<ViewStyle>;
20
+ }
21
+
22
+ /**
23
+ * Fixed-position screen header matching the Kaelo glass design system.
24
+ * Renders safe-area top inset + home icon + centered title/subtitle + optional right slot.
25
+ * Place as a sibling of (not inside) your ScrollView so it stays pinned while body scrolls.
26
+ */
27
+ export function ScreenHeader({
28
+ title,
29
+ subtitle,
30
+ leftIcon = 'home-outline',
31
+ leftAccessibilityLabel = 'Go to Dashboard',
32
+ onLeftPress,
33
+ right,
34
+ below,
35
+ style,
36
+ }: ScreenHeaderProps) {
37
+ const insets = useSafeAreaInsets();
38
+ const { colors } = useTheme();
39
+
40
+ return (
41
+ <View style={[styles.container, { paddingTop: insets.top + 16 }, style]}>
42
+ <View style={styles.row}>
43
+ <View style={styles.side}>
44
+ {onLeftPress && (
45
+ <TouchableOpacity
46
+ onPress={onLeftPress}
47
+ accessibilityLabel={leftAccessibilityLabel}
48
+ accessibilityRole="button"
49
+ hitSlop={8}
50
+ >
51
+ <Icon name={leftIcon as any} size={26} color={colors.icon.primary} />
52
+ </TouchableOpacity>
53
+ )}
54
+ </View>
55
+ <View style={styles.center}>
56
+ <Text
57
+ style={[styles.title, { color: colors.text.primary }]}
58
+ numberOfLines={1}
59
+ >
60
+ {title}
61
+ </Text>
62
+ {subtitle && (
63
+ <Text
64
+ style={[styles.subtitle, { color: colors.text.tertiary }]}
65
+ numberOfLines={1}
66
+ >
67
+ {subtitle}
68
+ </Text>
69
+ )}
70
+ </View>
71
+ <View style={styles.side}>{right}</View>
72
+ </View>
73
+ {below}
74
+ </View>
75
+ );
76
+ }
77
+
78
+ const styles = StyleSheet.create({
79
+ container: {
80
+ paddingHorizontal: 20,
81
+ paddingBottom: 8,
82
+ },
83
+ row: {
84
+ flexDirection: 'row',
85
+ alignItems: 'center',
86
+ marginBottom: 8,
87
+ },
88
+ side: {
89
+ width: 40,
90
+ alignItems: 'center',
91
+ justifyContent: 'center',
92
+ },
93
+ center: {
94
+ flex: 1,
95
+ alignItems: 'center',
96
+ },
97
+ title: {
98
+ fontSize: 22,
99
+ fontWeight: '700',
100
+ textAlign: 'center',
101
+ },
102
+ subtitle: {
103
+ fontSize: 13,
104
+ fontWeight: '500',
105
+ marginTop: 2,
106
+ },
107
+ });
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { Text, StyleSheet, StyleProp, TextStyle } from 'react-native';
3
+ import { useTheme } from '../hooks/useTheme';
4
+
5
+ export interface SectionHeaderProps {
6
+ children: React.ReactNode;
7
+ style?: StyleProp<TextStyle>;
8
+ }
9
+
10
+ /**
11
+ * Small uppercase section label used above lists of settings or cards.
12
+ * Matches the existing Profile / LogFood section headers.
13
+ */
14
+ export function SectionHeader({ children, style }: SectionHeaderProps) {
15
+ const { colors } = useTheme();
16
+ return (
17
+ <Text style={[styles.title, { color: colors.text.secondary }, style]}>
18
+ {children}
19
+ </Text>
20
+ );
21
+ }
22
+
23
+ const styles = StyleSheet.create({
24
+ title: {
25
+ fontSize: 13,
26
+ fontWeight: '700',
27
+ marginBottom: 12,
28
+ marginLeft: 4,
29
+ textTransform: 'uppercase',
30
+ letterSpacing: 1,
31
+ },
32
+ });
@@ -0,0 +1,158 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ StyleProp,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import { useTheme } from '../hooks/useTheme';
11
+
12
+ export interface SegmentedToggleOption<T extends string> {
13
+ value: T;
14
+ label: string;
15
+ accessibilityLabel?: string;
16
+ /** Render at 40% opacity and ignore taps. */
17
+ disabled?: boolean;
18
+ }
19
+
20
+ export interface SegmentedToggleProps<T extends string> {
21
+ options: SegmentedToggleOption<T>[];
22
+ value: T;
23
+ onChange: (value: T) => void;
24
+ /**
25
+ * `pill` — rounded pill row with white active background (primary tabs, e.g. Insights/History).
26
+ * `chip` — subtle inset variant for filter rows.
27
+ * `underline` — no container background; active tab has a 2px bar underneath. Best for secondary toggles (e.g. Week/Month).
28
+ */
29
+ variant?: 'pill' | 'chip' | 'underline';
30
+ style?: StyleProp<ViewStyle>;
31
+ }
32
+
33
+ /**
34
+ * Horizontal toggle used for Week/Month, Insights/History, etc.
35
+ * Replaces the ad-hoc tabBar/periodToggle rows in ProgressScreen and InsightsScreen.
36
+ */
37
+ export function SegmentedToggle<T extends string>({
38
+ options,
39
+ value,
40
+ onChange,
41
+ variant = 'pill',
42
+ style,
43
+ }: SegmentedToggleProps<T>) {
44
+ const { colors } = useTheme();
45
+ const isChip = variant === 'chip';
46
+ const isUnderline = variant === 'underline';
47
+
48
+ const containerBg = isUnderline
49
+ ? 'transparent'
50
+ : isChip
51
+ ? colors.glass.backgroundLight
52
+ : '#F0EFEA';
53
+ const activeBg = isChip ? colors.glass.backgroundHeavy : '#FFFFFF';
54
+
55
+ return (
56
+ <View
57
+ accessibilityRole="tablist"
58
+ style={[
59
+ styles.container,
60
+ { backgroundColor: containerBg },
61
+ isChip && styles.containerChip,
62
+ isUnderline && styles.containerUnderline,
63
+ style,
64
+ ]}
65
+ >
66
+ {options.map((opt) => {
67
+ const active = opt.value === value;
68
+ const disabled = !!opt.disabled;
69
+ return (
70
+ <TouchableOpacity
71
+ key={opt.value}
72
+ disabled={disabled}
73
+ onPress={() => onChange(opt.value)}
74
+ accessibilityRole="tab"
75
+ accessibilityLabel={opt.accessibilityLabel ?? opt.label}
76
+ accessibilityState={{ selected: active, disabled }}
77
+ style={[
78
+ styles.segment,
79
+ isUnderline && styles.segmentUnderline,
80
+ !isUnderline && active && { backgroundColor: activeBg },
81
+ !isUnderline && active && styles.segmentActiveShadow,
82
+ disabled && styles.segmentDisabled,
83
+ ]}
84
+ >
85
+ <Text
86
+ style={[
87
+ styles.label,
88
+ {
89
+ color: active ? colors.text.primary : colors.text.tertiary,
90
+ },
91
+ isUnderline && styles.labelUnderline,
92
+ ]}
93
+ >
94
+ {opt.label}
95
+ </Text>
96
+ {isUnderline && active && (
97
+ <View
98
+ style={[styles.underlineBar, { backgroundColor: colors.text.primary }]}
99
+ />
100
+ )}
101
+ </TouchableOpacity>
102
+ );
103
+ })}
104
+ </View>
105
+ );
106
+ }
107
+
108
+ const styles = StyleSheet.create({
109
+ container: {
110
+ flexDirection: 'row',
111
+ borderRadius: 12,
112
+ padding: 4,
113
+ },
114
+ containerChip: {
115
+ borderRadius: 10,
116
+ },
117
+ containerUnderline: {
118
+ borderRadius: 0,
119
+ padding: 0,
120
+ },
121
+ segment: {
122
+ flex: 1,
123
+ paddingVertical: 10,
124
+ alignItems: 'center',
125
+ borderRadius: 10,
126
+ },
127
+ segmentUnderline: {
128
+ paddingVertical: 8,
129
+ borderRadius: 0,
130
+ },
131
+ segmentActiveShadow: {
132
+ shadowColor: '#000',
133
+ shadowOffset: { width: 0, height: 1 },
134
+ shadowOpacity: 0.06,
135
+ shadowRadius: 3,
136
+ elevation: 1,
137
+ },
138
+ segmentDisabled: {
139
+ opacity: 0.4,
140
+ },
141
+ label: {
142
+ fontSize: 14,
143
+ fontWeight: '600',
144
+ },
145
+ labelUnderline: {
146
+ fontSize: 13,
147
+ fontWeight: '700',
148
+ letterSpacing: 0.2,
149
+ },
150
+ underlineBar: {
151
+ position: 'absolute',
152
+ bottom: 0,
153
+ left: '20%',
154
+ right: '20%',
155
+ height: 2,
156
+ borderRadius: 2,
157
+ },
158
+ });
@@ -5,6 +5,16 @@ import { useTheme } from '../../hooks/useTheme';
5
5
 
6
6
  const AnimatedCircle = Animated.createAnimatedComponent(Circle);
7
7
 
8
+ /**
9
+ * Display mode for the center text:
10
+ * - `consumed` → shows `current` (e.g. "747") with `/ target` underneath.
11
+ * - `remaining` → shows `max(0, target - current)` (e.g. "681 cal left").
12
+ * - `percent` → shows "NN%".
13
+ *
14
+ * `mode` supersedes the legacy `showPercentage` / `showRemaining` booleans.
15
+ */
16
+ export type CircularProgressMode = 'consumed' | 'remaining' | 'percent';
17
+
8
18
  interface CircularProgressProps {
9
19
  size?: number;
10
20
  strokeWidth?: number;
@@ -13,7 +23,10 @@ interface CircularProgressProps {
13
23
  color?: string;
14
24
  label?: string;
15
25
  unit?: string;
26
+ mode?: CircularProgressMode;
27
+ /** @deprecated Use `mode="percent"`. */
16
28
  showPercentage?: boolean;
29
+ /** @deprecated Use `mode="remaining"`. */
17
30
  showRemaining?: boolean;
18
31
  subtitle?: string;
19
32
  valueSize?: number;
@@ -29,6 +42,7 @@ export const CircularProgress: React.FC<CircularProgressProps> = ({
29
42
  color,
30
43
  label,
31
44
  unit = '',
45
+ mode,
32
46
  showPercentage = false,
33
47
  showRemaining = false,
34
48
  subtitle,
@@ -40,6 +54,15 @@ export const CircularProgress: React.FC<CircularProgressProps> = ({
40
54
  const styles = createStyles(colors);
41
55
  const resolvedColor = color ?? colors.text.primary;
42
56
 
57
+ if (__DEV__ && mode && (showPercentage || showRemaining)) {
58
+ console.warn(
59
+ '[CircularProgress] `mode` is set; ignoring deprecated `showPercentage` / `showRemaining`.',
60
+ );
61
+ }
62
+ const resolvedMode: CircularProgressMode =
63
+ mode ??
64
+ (showPercentage ? 'percent' : showRemaining ? 'remaining' : 'consumed');
65
+
43
66
  const animatedValue = useRef(new Animated.Value(0)).current;
44
67
  const circleRef = useRef<any>(null);
45
68
 
@@ -101,14 +124,22 @@ export const CircularProgress: React.FC<CircularProgressProps> = ({
101
124
  <View style={styles.textContainer}>
102
125
  {label && <Text style={styles.label}>{label}</Text>}
103
126
  <Text style={[styles.value, valueSize ? { fontSize: valueSize } : undefined]}>
104
- {showRemaining ? Math.max(0, Math.round(target - current)) : Math.round(current)}
105
- {unit && <Text style={[styles.unit, valueSize ? { fontSize: valueSize * 0.58 } : undefined]}>{unit}</Text>}
127
+ {resolvedMode === 'percent'
128
+ ? `${Math.round(percentage)}`
129
+ : resolvedMode === 'remaining'
130
+ ? Math.max(0, Math.round(target - current))
131
+ : Math.round(current)}
132
+ {(resolvedMode === 'percent' ? '%' : unit) && (
133
+ <Text style={[styles.unit, valueSize ? { fontSize: valueSize * 0.58 } : undefined]}>
134
+ {resolvedMode === 'percent' ? '%' : unit}
135
+ </Text>
136
+ )}
106
137
  </Text>
107
138
  {subtitle ? (
108
139
  <Text style={[styles.subtitle, subtitleSize ? { fontSize: subtitleSize } : undefined]}>{subtitle}</Text>
109
- ) : target > 0 ? (
140
+ ) : target > 0 && resolvedMode === 'consumed' ? (
110
141
  <Text style={styles.target}>
111
- {showPercentage ? `${Math.round(percentage)}%` : `/ ${Math.round(target)}${unit}`}
142
+ / {Math.round(target)}{unit}
112
143
  </Text>
113
144
  ) : null}
114
145
  </View>
@@ -59,7 +59,14 @@ export const MacroBar: React.FC<MacroBarProps> = ({
59
59
  </Animated.View>
60
60
  </View>
61
61
  {percentage > 0 && (
62
- <Text style={styles.percentage}>{Math.round(percentage)}%</Text>
62
+ <Text
63
+ style={[
64
+ styles.percentage,
65
+ percentage >= 85 && styles.percentageOnFill,
66
+ ]}
67
+ >
68
+ {Math.round(percentage)}%
69
+ </Text>
63
70
  )}
64
71
  </View>
65
72
  </View>
@@ -116,4 +123,7 @@ const createStyles = (colors: any) => StyleSheet.create({
116
123
  textAlignVertical: 'center',
117
124
  lineHeight: 28,
118
125
  },
126
+ percentageOnFill: {
127
+ color: '#FFFFFF',
128
+ },
119
129
  });
package/src/index.ts CHANGED
@@ -22,8 +22,27 @@ export { GlassHeaderBackground } from './components/GlassHeaderBackground';
22
22
  export { GlassInput } from './components/GlassInput';
23
23
  export { GlassListItem } from './components/GlassList';
24
24
  export { GlassStatsCard, GlassMacroCard, GlassNutrientRow } from './components/GlassStats';
25
+ export { RadioOption, RadioGroup } from './components/RadioOption';
26
+ export type { RadioOptionProps, RadioGroupProps } from './components/RadioOption';
27
+ export { ScreenHeader } from './components/ScreenHeader';
28
+ export type { ScreenHeaderProps } from './components/ScreenHeader';
29
+ export { SectionHeader } from './components/SectionHeader';
30
+ export type { SectionHeaderProps } from './components/SectionHeader';
31
+ export { SegmentedToggle } from './components/SegmentedToggle';
32
+ export type { SegmentedToggleProps, SegmentedToggleOption } from './components/SegmentedToggle';
33
+ export { EmptyState } from './components/EmptyState';
34
+ export type { EmptyStateProps, EmptyStateVariant } from './components/EmptyState';
35
+ export { ProgressCard } from './components/ProgressCard';
36
+ export type { ProgressCardProps } from './components/ProgressCard';
37
+ export { RecipeRow } from './components/RecipeRow';
38
+ export type { RecipeRowProps } from './components/RecipeRow';
39
+ export { ErrorState } from './components/ErrorState';
40
+ export type { ErrorStateProps } from './components/ErrorState';
41
+ export { LoadingState } from './components/LoadingState';
42
+ export type { LoadingStateProps } from './components/LoadingState';
25
43
 
26
44
  // Progress
27
45
  export { CircularProgress } from './components/progress/CircularProgress';
46
+ export type { CircularProgressMode } from './components/progress/CircularProgress';
28
47
  export { CArcProgress } from './components/progress/CArcProgress';
29
48
  export { MacroBar } from './components/progress/MacroBar';