@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 +1 -1
- package/src/components/EmptyState.tsx +26 -4
- package/src/components/FAB.tsx +31 -0
- package/src/components/ProgressCard.tsx +227 -0
- package/src/components/RecipeRow.tsx +261 -0
- package/src/components/SegmentedToggle.tsx +53 -7
- package/src/components/progress/CircularProgress.tsx +35 -4
- package/src/components/progress/MacroBar.tsx +11 -1
- package/src/index.ts +6 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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={
|
|
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 }]}>
|
package/src/components/FAB.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
66
|
-
active &&
|
|
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
|
-
{
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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';
|