@klyftig/mobile-ui 1.1.0 → 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.1.0",
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
  },
@@ -3,8 +3,18 @@ import { View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
3
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
4
  import { useTheme } from '../hooks/useTheme';
5
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
+
6
16
  export interface EmptyStateProps {
7
- /** MaterialCommunityIcons name. Defaults to "inbox-outline". */
17
+ /** MaterialCommunityIcons name. Defaults vary by variant. */
8
18
  icon?: string;
9
19
  iconSize?: number;
10
20
  iconColor?: string;
@@ -12,27 +22,39 @@ export interface EmptyStateProps {
12
22
  description?: string;
13
23
  /** Render an action button (or any node) below the description. */
14
24
  action?: React.ReactNode;
25
+ variant?: EmptyStateVariant;
15
26
  style?: StyleProp<ViewStyle>;
16
27
  }
17
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
+
18
35
  /**
19
36
  * Large-icon empty state used on list/feed screens with no data.
20
37
  * Centered in its parent; pass a flex-1 wrapper to vertically center on a full screen.
21
38
  */
22
39
  export function EmptyState({
23
- icon = 'inbox-outline',
40
+ icon,
24
41
  iconSize = 64,
25
42
  iconColor,
26
43
  title,
27
44
  description,
28
45
  action,
46
+ variant = 'empty',
29
47
  style,
30
48
  }: EmptyStateProps) {
31
49
  const { colors } = useTheme();
32
- const resolvedIconColor = iconColor ?? colors.icon.tertiary;
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
+
33
55
  return (
34
56
  <View style={[styles.container, style]}>
35
- <Icon name={icon as any} size={iconSize} color={resolvedIconColor} />
57
+ <Icon name={resolvedIcon as any} size={iconSize} color={resolvedIconColor} />
36
58
  <Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
37
59
  {description && (
38
60
  <Text style={[styles.description, { color: colors.text.tertiary }]}>
@@ -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,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,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
+ });
@@ -13,19 +13,25 @@ export interface SegmentedToggleOption<T extends string> {
13
13
  value: T;
14
14
  label: string;
15
15
  accessibilityLabel?: string;
16
+ /** Render at 40% opacity and ignore taps. */
17
+ disabled?: boolean;
16
18
  }
17
19
 
18
20
  export interface SegmentedToggleProps<T extends string> {
19
21
  options: SegmentedToggleOption<T>[];
20
22
  value: T;
21
23
  onChange: (value: T) => void;
22
- /** `"pill"` = rounded pill row (tabs); `"chip"` = subtle inset toggle (filters). */
23
- variant?: 'pill' | 'chip';
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';
24
30
  style?: StyleProp<ViewStyle>;
25
31
  }
26
32
 
27
33
  /**
28
- * Horizontal pill toggle used for Week/Month, Insights/History, etc.
34
+ * Horizontal toggle used for Week/Month, Insights/History, etc.
29
35
  * Replaces the ad-hoc tabBar/periodToggle rows in ProgressScreen and InsightsScreen.
30
36
  */
31
37
  export function SegmentedToggle<T extends string>({
@@ -37,8 +43,13 @@ export function SegmentedToggle<T extends string>({
37
43
  }: SegmentedToggleProps<T>) {
38
44
  const { colors } = useTheme();
39
45
  const isChip = variant === 'chip';
46
+ const isUnderline = variant === 'underline';
40
47
 
41
- const containerBg = isChip ? colors.glass.backgroundLight : '#F0EFEA';
48
+ const containerBg = isUnderline
49
+ ? 'transparent'
50
+ : isChip
51
+ ? colors.glass.backgroundLight
52
+ : '#F0EFEA';
42
53
  const activeBg = isChip ? colors.glass.backgroundHeavy : '#FFFFFF';
43
54
 
44
55
  return (
@@ -48,22 +59,27 @@ export function SegmentedToggle<T extends string>({
48
59
  styles.container,
49
60
  { backgroundColor: containerBg },
50
61
  isChip && styles.containerChip,
62
+ isUnderline && styles.containerUnderline,
51
63
  style,
52
64
  ]}
53
65
  >
54
66
  {options.map((opt) => {
55
67
  const active = opt.value === value;
68
+ const disabled = !!opt.disabled;
56
69
  return (
57
70
  <TouchableOpacity
58
71
  key={opt.value}
72
+ disabled={disabled}
59
73
  onPress={() => onChange(opt.value)}
60
74
  accessibilityRole="tab"
61
75
  accessibilityLabel={opt.accessibilityLabel ?? opt.label}
62
- accessibilityState={{ selected: active }}
76
+ accessibilityState={{ selected: active, disabled }}
63
77
  style={[
64
78
  styles.segment,
65
- active && { backgroundColor: activeBg },
66
- active && styles.segmentActiveShadow,
79
+ isUnderline && styles.segmentUnderline,
80
+ !isUnderline && active && { backgroundColor: activeBg },
81
+ !isUnderline && active && styles.segmentActiveShadow,
82
+ disabled && styles.segmentDisabled,
67
83
  ]}
68
84
  >
69
85
  <Text
@@ -72,10 +88,16 @@ export function SegmentedToggle<T extends string>({
72
88
  {
73
89
  color: active ? colors.text.primary : colors.text.tertiary,
74
90
  },
91
+ isUnderline && styles.labelUnderline,
75
92
  ]}
76
93
  >
77
94
  {opt.label}
78
95
  </Text>
96
+ {isUnderline && active && (
97
+ <View
98
+ style={[styles.underlineBar, { backgroundColor: colors.text.primary }]}
99
+ />
100
+ )}
79
101
  </TouchableOpacity>
80
102
  );
81
103
  })}
@@ -92,12 +114,20 @@ const styles = StyleSheet.create({
92
114
  containerChip: {
93
115
  borderRadius: 10,
94
116
  },
117
+ containerUnderline: {
118
+ borderRadius: 0,
119
+ padding: 0,
120
+ },
95
121
  segment: {
96
122
  flex: 1,
97
123
  paddingVertical: 10,
98
124
  alignItems: 'center',
99
125
  borderRadius: 10,
100
126
  },
127
+ segmentUnderline: {
128
+ paddingVertical: 8,
129
+ borderRadius: 0,
130
+ },
101
131
  segmentActiveShadow: {
102
132
  shadowColor: '#000',
103
133
  shadowOffset: { width: 0, height: 1 },
@@ -105,8 +135,24 @@ const styles = StyleSheet.create({
105
135
  shadowRadius: 3,
106
136
  elevation: 1,
107
137
  },
138
+ segmentDisabled: {
139
+ opacity: 0.4,
140
+ },
108
141
  label: {
109
142
  fontSize: 14,
110
143
  fontWeight: '600',
111
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
+ },
112
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
@@ -31,7 +31,11 @@ export type { SectionHeaderProps } from './components/SectionHeader';
31
31
  export { SegmentedToggle } from './components/SegmentedToggle';
32
32
  export type { SegmentedToggleProps, SegmentedToggleOption } from './components/SegmentedToggle';
33
33
  export { EmptyState } from './components/EmptyState';
34
- export type { EmptyStateProps } 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';
35
39
  export { ErrorState } from './components/ErrorState';
36
40
  export type { ErrorStateProps } from './components/ErrorState';
37
41
  export { LoadingState } from './components/LoadingState';
@@ -39,5 +43,6 @@ export type { LoadingStateProps } from './components/LoadingState';
39
43
 
40
44
  // Progress
41
45
  export { CircularProgress } from './components/progress/CircularProgress';
46
+ export type { CircularProgressMode } from './components/progress/CircularProgress';
42
47
  export { CArcProgress } from './components/progress/CArcProgress';
43
48
  export { MacroBar } from './components/progress/MacroBar';