@klyftig/mobile-ui 1.0.2
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 +32 -0
- package/src/components/AdaptiveGlassView.tsx +46 -0
- package/src/components/ErrorBoundary.tsx +92 -0
- package/src/components/FAB.tsx +209 -0
- package/src/components/GlassButton.tsx +279 -0
- package/src/components/GlassCard.tsx +59 -0
- package/src/components/GlassContainer.tsx +26 -0
- package/src/components/GlassHeaderBackground.tsx +32 -0
- package/src/components/GlassInput.tsx +166 -0
- package/src/components/GlassList.tsx +97 -0
- package/src/components/GlassStats.tsx +212 -0
- package/src/components/progress/CArcProgress.tsx +107 -0
- package/src/components/progress/CircularProgress.tsx +157 -0
- package/src/components/progress/MacroBar.tsx +119 -0
- package/src/hooks/useTheme.ts +11 -0
- package/src/index.ts +29 -0
- package/src/theme/colors.ts +78 -0
- package/src/theme/spacing.ts +13 -0
- package/src/theme/theme.ts +46 -0
- package/src/theme/typography.ts +56 -0
- package/src/utils/glass.ts +12 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, ViewStyle } from 'react-native';
|
|
3
|
+
import { AdaptiveGlassView } from './AdaptiveGlassView';
|
|
4
|
+
import { useTheme } from '../hooks/useTheme';
|
|
5
|
+
|
|
6
|
+
interface GlassCardProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
style?: ViewStyle;
|
|
9
|
+
intensity?: number;
|
|
10
|
+
tint?: 'light' | 'dark' | 'default';
|
|
11
|
+
padding?: number;
|
|
12
|
+
useGlass?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const GlassCard: React.FC<GlassCardProps> = ({
|
|
16
|
+
children,
|
|
17
|
+
style,
|
|
18
|
+
padding = 16,
|
|
19
|
+
useGlass = false,
|
|
20
|
+
}) => {
|
|
21
|
+
const { colors } = useTheme();
|
|
22
|
+
|
|
23
|
+
if (useGlass) {
|
|
24
|
+
return (
|
|
25
|
+
<AdaptiveGlassView
|
|
26
|
+
variant="regular"
|
|
27
|
+
style={[styles.card, { padding }, style]}
|
|
28
|
+
fallbackStyle={{ backgroundColor: colors.background.card, borderColor: colors.border.light }}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</AdaptiveGlassView>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<View
|
|
37
|
+
style={[
|
|
38
|
+
styles.card,
|
|
39
|
+
{ backgroundColor: colors.background.card, borderColor: colors.border.light },
|
|
40
|
+
{ padding },
|
|
41
|
+
style,
|
|
42
|
+
]}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const styles = StyleSheet.create({
|
|
50
|
+
card: {
|
|
51
|
+
borderRadius: 20,
|
|
52
|
+
borderWidth: 1,
|
|
53
|
+
shadowColor: '#000',
|
|
54
|
+
shadowOffset: { width: 0, height: 2 },
|
|
55
|
+
shadowOpacity: 0.06,
|
|
56
|
+
shadowRadius: 8,
|
|
57
|
+
elevation: 3,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
interface GlassContainerProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
style?: ViewStyle;
|
|
7
|
+
gradientColors?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const GlassContainer: React.FC<GlassContainerProps> = ({
|
|
11
|
+
children,
|
|
12
|
+
style,
|
|
13
|
+
}) => {
|
|
14
|
+
return (
|
|
15
|
+
<View style={[styles.container, style]}>
|
|
16
|
+
{children}
|
|
17
|
+
</View>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
container: {
|
|
23
|
+
flex: 1,
|
|
24
|
+
backgroundColor: '#FFFFFF',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import { AdaptiveGlassView } from './AdaptiveGlassView';
|
|
4
|
+
import { colors } from '../theme/colors';
|
|
5
|
+
|
|
6
|
+
interface GlassHeaderBackgroundProps {
|
|
7
|
+
/** Tint color for the glass material (e.g. 'rgba(0,0,0,0.3)' for dark headers) */
|
|
8
|
+
tintColor?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reusable header background using AdaptiveGlassView.
|
|
13
|
+
* Usage: `headerBackground: () => <GlassHeaderBackground />`
|
|
14
|
+
*/
|
|
15
|
+
export const GlassHeaderBackground: React.FC<GlassHeaderBackgroundProps> = ({
|
|
16
|
+
tintColor,
|
|
17
|
+
}) => (
|
|
18
|
+
<AdaptiveGlassView
|
|
19
|
+
variant="regular"
|
|
20
|
+
tintColor={tintColor}
|
|
21
|
+
style={styles.fill}
|
|
22
|
+
fallbackStyle={{
|
|
23
|
+
backgroundColor: tintColor || colors.glass.headerFallback,
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const styles = StyleSheet.create({
|
|
29
|
+
fill: {
|
|
30
|
+
flex: 1,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React, { useState, useCallback, memo } from 'react';
|
|
2
|
+
import { View, TextInput as RNTextInput, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
3
|
+
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
4
|
+
import { useTheme } from '../hooks/useTheme';
|
|
5
|
+
|
|
6
|
+
interface GlassInputProps {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
onChangeText: (text: string) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
secureTextEntry?: boolean;
|
|
12
|
+
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
|
|
13
|
+
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
icon?: string;
|
|
16
|
+
showPasswordToggle?: boolean;
|
|
17
|
+
rightText?: string;
|
|
18
|
+
textContentType?: 'none' | 'password' | 'newPassword' | 'emailAddress' | 'username' | 'oneTimeCode';
|
|
19
|
+
autoComplete?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
23
|
+
label,
|
|
24
|
+
value,
|
|
25
|
+
onChangeText,
|
|
26
|
+
placeholder,
|
|
27
|
+
secureTextEntry = false,
|
|
28
|
+
keyboardType = 'default',
|
|
29
|
+
autoCapitalize = 'sentences',
|
|
30
|
+
disabled = false,
|
|
31
|
+
icon,
|
|
32
|
+
showPasswordToggle = false,
|
|
33
|
+
rightText,
|
|
34
|
+
textContentType,
|
|
35
|
+
autoComplete,
|
|
36
|
+
}) => {
|
|
37
|
+
const { colors } = useTheme();
|
|
38
|
+
const [isSecure, setIsSecure] = useState(secureTextEntry);
|
|
39
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
40
|
+
|
|
41
|
+
const handleFocus = useCallback(() => {
|
|
42
|
+
setIsFocused(true);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const handleBlur = useCallback(() => {
|
|
46
|
+
setIsFocused(false);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const toggleSecure = useCallback(() => {
|
|
50
|
+
setIsSecure(prev => !prev);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={styles.container}>
|
|
55
|
+
<Text style={[styles.label, { color: colors.text.primary }]}>{label}</Text>
|
|
56
|
+
<View
|
|
57
|
+
style={[
|
|
58
|
+
styles.inputContainer,
|
|
59
|
+
isFocused && styles.inputContainerFocused,
|
|
60
|
+
disabled && styles.inputContainerDisabled,
|
|
61
|
+
]}
|
|
62
|
+
>
|
|
63
|
+
<View style={styles.inputWrapper}>
|
|
64
|
+
{icon && (
|
|
65
|
+
<Icon name={icon} size={20} color="#9B9B9B" style={styles.leftIcon} />
|
|
66
|
+
)}
|
|
67
|
+
<RNTextInput
|
|
68
|
+
value={value}
|
|
69
|
+
onChangeText={onChangeText}
|
|
70
|
+
placeholder={placeholder}
|
|
71
|
+
placeholderTextColor="#C5C5C5"
|
|
72
|
+
secureTextEntry={isSecure}
|
|
73
|
+
keyboardType={keyboardType}
|
|
74
|
+
autoCapitalize={autoCapitalize}
|
|
75
|
+
editable={!disabled}
|
|
76
|
+
onFocus={handleFocus}
|
|
77
|
+
onBlur={handleBlur}
|
|
78
|
+
textContentType={textContentType ?? (secureTextEntry ? 'oneTimeCode' : undefined)}
|
|
79
|
+
autoComplete={autoComplete as any}
|
|
80
|
+
accessibilityLabel={label}
|
|
81
|
+
accessibilityHint={placeholder ? `Enter ${label.toLowerCase()}` : undefined}
|
|
82
|
+
accessibilityState={{ disabled }}
|
|
83
|
+
style={[styles.input, icon && styles.inputWithLeftIcon, (rightText || showPasswordToggle) && styles.inputWithRightIcon]}
|
|
84
|
+
/>
|
|
85
|
+
{rightText && (
|
|
86
|
+
<Text style={styles.rightText}>{rightText}</Text>
|
|
87
|
+
)}
|
|
88
|
+
{showPasswordToggle && (
|
|
89
|
+
<TouchableOpacity
|
|
90
|
+
onPress={toggleSecure}
|
|
91
|
+
style={styles.eyeIcon}
|
|
92
|
+
activeOpacity={0.7}
|
|
93
|
+
accessibilityRole="button"
|
|
94
|
+
accessibilityLabel={isSecure ? 'Show password' : 'Hide password'}
|
|
95
|
+
>
|
|
96
|
+
<Icon
|
|
97
|
+
name={isSecure ? 'eye-off' : 'eye'}
|
|
98
|
+
size={20}
|
|
99
|
+
color="#9B9B9B"
|
|
100
|
+
/>
|
|
101
|
+
</TouchableOpacity>
|
|
102
|
+
)}
|
|
103
|
+
</View>
|
|
104
|
+
</View>
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const GlassInput = memo(GlassInputComponent);
|
|
110
|
+
|
|
111
|
+
const styles = StyleSheet.create({
|
|
112
|
+
container: {
|
|
113
|
+
marginBottom: 16,
|
|
114
|
+
},
|
|
115
|
+
label: {
|
|
116
|
+
fontSize: 14,
|
|
117
|
+
fontWeight: '600',
|
|
118
|
+
marginBottom: 8,
|
|
119
|
+
marginLeft: 4,
|
|
120
|
+
},
|
|
121
|
+
inputContainer: {
|
|
122
|
+
borderRadius: 16,
|
|
123
|
+
borderWidth: 1,
|
|
124
|
+
backgroundColor: '#F5F5F2',
|
|
125
|
+
borderColor: 'rgba(0,0,0,0.08)',
|
|
126
|
+
},
|
|
127
|
+
inputContainerFocused: {
|
|
128
|
+
borderColor: '#1A1A1A',
|
|
129
|
+
backgroundColor: '#FFFFFF',
|
|
130
|
+
},
|
|
131
|
+
inputContainerDisabled: {
|
|
132
|
+
opacity: 0.6,
|
|
133
|
+
},
|
|
134
|
+
inputWrapper: {
|
|
135
|
+
flexDirection: 'row',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
paddingHorizontal: 16,
|
|
138
|
+
height: 56,
|
|
139
|
+
},
|
|
140
|
+
leftIcon: {
|
|
141
|
+
marginRight: 12,
|
|
142
|
+
},
|
|
143
|
+
input: {
|
|
144
|
+
flex: 1,
|
|
145
|
+
fontSize: 16,
|
|
146
|
+
height: '100%',
|
|
147
|
+
paddingVertical: 0,
|
|
148
|
+
color: '#1A1A1A',
|
|
149
|
+
},
|
|
150
|
+
inputWithLeftIcon: {
|
|
151
|
+
marginLeft: 0,
|
|
152
|
+
},
|
|
153
|
+
inputWithRightIcon: {
|
|
154
|
+
paddingRight: 8,
|
|
155
|
+
},
|
|
156
|
+
rightText: {
|
|
157
|
+
fontSize: 16,
|
|
158
|
+
fontWeight: '600',
|
|
159
|
+
marginLeft: 8,
|
|
160
|
+
color: '#6B6B6B',
|
|
161
|
+
},
|
|
162
|
+
eyeIcon: {
|
|
163
|
+
padding: 8,
|
|
164
|
+
marginRight: -8,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';
|
|
3
|
+
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
4
|
+
import { useTheme } from '../hooks/useTheme';
|
|
5
|
+
|
|
6
|
+
interface GlassListItemProps {
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
leftIcon?: string;
|
|
10
|
+
rightIcon?: string;
|
|
11
|
+
onPress?: () => void;
|
|
12
|
+
style?: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const GlassListItem: React.FC<GlassListItemProps> = ({
|
|
16
|
+
title,
|
|
17
|
+
description,
|
|
18
|
+
leftIcon,
|
|
19
|
+
rightIcon,
|
|
20
|
+
onPress,
|
|
21
|
+
style,
|
|
22
|
+
}) => {
|
|
23
|
+
const { colors } = useTheme();
|
|
24
|
+
|
|
25
|
+
const content = (
|
|
26
|
+
<View
|
|
27
|
+
style={[
|
|
28
|
+
styles.listItem,
|
|
29
|
+
{ backgroundColor: colors.background.card, borderColor: colors.border.light },
|
|
30
|
+
style,
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
<View style={styles.listItemContent}>
|
|
34
|
+
{leftIcon && (
|
|
35
|
+
<Icon name={leftIcon} size={24} color={colors.icon.primary} style={styles.leftIcon} />
|
|
36
|
+
)}
|
|
37
|
+
<View style={styles.textContainer}>
|
|
38
|
+
<Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
|
|
39
|
+
{description && <Text style={[styles.description, { color: colors.text.secondary }]}>{description}</Text>}
|
|
40
|
+
</View>
|
|
41
|
+
{rightIcon && (
|
|
42
|
+
<Icon name={rightIcon} size={20} color={colors.icon.tertiary} style={styles.rightIcon} />
|
|
43
|
+
)}
|
|
44
|
+
</View>
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (onPress) {
|
|
49
|
+
return (
|
|
50
|
+
<TouchableOpacity
|
|
51
|
+
onPress={onPress}
|
|
52
|
+
activeOpacity={0.8}
|
|
53
|
+
accessibilityRole="button"
|
|
54
|
+
accessibilityLabel={description ? `${title}, ${description}` : title}
|
|
55
|
+
>
|
|
56
|
+
{content}
|
|
57
|
+
</TouchableOpacity>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return content;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const styles = StyleSheet.create({
|
|
65
|
+
listItem: {
|
|
66
|
+
borderRadius: 16,
|
|
67
|
+
borderWidth: 1,
|
|
68
|
+
marginBottom: 12,
|
|
69
|
+
shadowColor: '#000',
|
|
70
|
+
shadowOffset: { width: 0, height: 2 },
|
|
71
|
+
shadowOpacity: 0.06,
|
|
72
|
+
shadowRadius: 8,
|
|
73
|
+
elevation: 3,
|
|
74
|
+
},
|
|
75
|
+
listItemContent: {
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
padding: 16,
|
|
79
|
+
},
|
|
80
|
+
leftIcon: {
|
|
81
|
+
marginRight: 16,
|
|
82
|
+
},
|
|
83
|
+
textContainer: {
|
|
84
|
+
flex: 1,
|
|
85
|
+
},
|
|
86
|
+
title: {
|
|
87
|
+
fontSize: 16,
|
|
88
|
+
fontWeight: '600',
|
|
89
|
+
marginBottom: 4,
|
|
90
|
+
},
|
|
91
|
+
description: {
|
|
92
|
+
fontSize: 14,
|
|
93
|
+
},
|
|
94
|
+
rightIcon: {
|
|
95
|
+
marginLeft: 12,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
4
|
+
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
5
|
+
import { AdaptiveGlassView } from './AdaptiveGlassView';
|
|
6
|
+
import { useTheme } from '../hooks/useTheme';
|
|
7
|
+
|
|
8
|
+
interface GlassStatsCardProps {
|
|
9
|
+
icon: string;
|
|
10
|
+
title: string;
|
|
11
|
+
value: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
gradientColors?: string[];
|
|
14
|
+
progress?: number; // 0-100
|
|
15
|
+
useGlass?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const GlassStatsCard: React.FC<GlassStatsCardProps> = ({
|
|
19
|
+
icon,
|
|
20
|
+
title,
|
|
21
|
+
value,
|
|
22
|
+
subtitle,
|
|
23
|
+
gradientColors = ['rgba(255,255,255,0.3)', 'rgba(255,255,255,0.1)'],
|
|
24
|
+
progress,
|
|
25
|
+
useGlass = false,
|
|
26
|
+
}) => {
|
|
27
|
+
const { colors } = useTheme();
|
|
28
|
+
|
|
29
|
+
const cardChildren = (
|
|
30
|
+
<>
|
|
31
|
+
<Icon name={icon} size={32} color={colors.icon.primary} style={styles.icon} />
|
|
32
|
+
<Text style={[styles.title, { color: colors.text.secondary }]}>{title}</Text>
|
|
33
|
+
<Text style={[styles.value, { color: colors.text.primary }]}>{value}</Text>
|
|
34
|
+
{subtitle && <Text style={[styles.subtitle, { color: colors.text.secondary }]}>{subtitle}</Text>}
|
|
35
|
+
{progress !== undefined && (
|
|
36
|
+
<View style={styles.progressContainer}>
|
|
37
|
+
<View style={[styles.progressBar, { width: `${Math.min(progress, 100)}%` }]} />
|
|
38
|
+
</View>
|
|
39
|
+
)}
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View style={styles.card}>
|
|
45
|
+
{useGlass ? (
|
|
46
|
+
<AdaptiveGlassView
|
|
47
|
+
variant="regular"
|
|
48
|
+
style={styles.gradient}
|
|
49
|
+
fallbackStyle={{ backgroundColor: '#FFFFFF' }}
|
|
50
|
+
>
|
|
51
|
+
{cardChildren}
|
|
52
|
+
</AdaptiveGlassView>
|
|
53
|
+
) : (
|
|
54
|
+
<LinearGradient
|
|
55
|
+
colors={gradientColors as [string, string, ...string[]]}
|
|
56
|
+
start={{ x: 0, y: 0 }}
|
|
57
|
+
end={{ x: 1, y: 1 }}
|
|
58
|
+
style={styles.gradient}
|
|
59
|
+
>
|
|
60
|
+
{cardChildren}
|
|
61
|
+
</LinearGradient>
|
|
62
|
+
)}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
interface GlassMacroCardProps {
|
|
68
|
+
icon: string;
|
|
69
|
+
label: string;
|
|
70
|
+
value: string;
|
|
71
|
+
gradientColors: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const GlassMacroCard: React.FC<GlassMacroCardProps> = ({
|
|
75
|
+
icon,
|
|
76
|
+
label,
|
|
77
|
+
value,
|
|
78
|
+
gradientColors,
|
|
79
|
+
}) => {
|
|
80
|
+
const { colors } = useTheme();
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<View style={[styles.macroCard, { borderColor: colors.border.light }]}>
|
|
84
|
+
<LinearGradient
|
|
85
|
+
colors={gradientColors as [string, string, ...string[]]}
|
|
86
|
+
start={{ x: 0, y: 0 }}
|
|
87
|
+
end={{ x: 1, y: 1 }}
|
|
88
|
+
style={styles.macroGradient}
|
|
89
|
+
>
|
|
90
|
+
<Icon name={icon} size={28} color={colors.icon.primary} />
|
|
91
|
+
<Text style={[styles.macroLabel, { color: colors.text.secondary }]}>{label}</Text>
|
|
92
|
+
<Text style={[styles.macroValue, { color: colors.text.primary }]}>{value}</Text>
|
|
93
|
+
</LinearGradient>
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
interface GlassNutrientRowProps {
|
|
99
|
+
icon: string;
|
|
100
|
+
label: string;
|
|
101
|
+
value: string;
|
|
102
|
+
iconColor?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const GlassNutrientRow: React.FC<GlassNutrientRowProps> = ({
|
|
106
|
+
icon,
|
|
107
|
+
label,
|
|
108
|
+
value,
|
|
109
|
+
iconColor = '#6B6B6B',
|
|
110
|
+
}) => {
|
|
111
|
+
const { colors } = useTheme();
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<View style={[styles.nutrientRow, { borderBottomColor: colors.border.light }]}>
|
|
115
|
+
<View style={styles.nutrientLeft}>
|
|
116
|
+
<Icon name={icon} size={22} color={iconColor} style={styles.nutrientIcon} />
|
|
117
|
+
<Text style={[styles.nutrientLabel, { color: colors.text.primary }]}>{label}</Text>
|
|
118
|
+
</View>
|
|
119
|
+
<Text style={[styles.nutrientValue, { color: colors.text.primary }]}>{value}</Text>
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const styles = StyleSheet.create({
|
|
125
|
+
card: {
|
|
126
|
+
borderRadius: 20,
|
|
127
|
+
overflow: 'hidden',
|
|
128
|
+
backgroundColor: '#FFFFFF',
|
|
129
|
+
borderWidth: 1,
|
|
130
|
+
borderColor: 'rgba(0,0,0,0.06)',
|
|
131
|
+
shadowColor: '#000',
|
|
132
|
+
shadowOffset: { width: 0, height: 2 },
|
|
133
|
+
shadowOpacity: 0.06,
|
|
134
|
+
shadowRadius: 8,
|
|
135
|
+
elevation: 3,
|
|
136
|
+
marginBottom: 16,
|
|
137
|
+
},
|
|
138
|
+
gradient: {
|
|
139
|
+
padding: 20,
|
|
140
|
+
},
|
|
141
|
+
icon: {
|
|
142
|
+
marginBottom: 12,
|
|
143
|
+
},
|
|
144
|
+
title: {
|
|
145
|
+
fontSize: 16,
|
|
146
|
+
fontWeight: '600',
|
|
147
|
+
marginBottom: 8,
|
|
148
|
+
},
|
|
149
|
+
value: {
|
|
150
|
+
fontSize: 36,
|
|
151
|
+
fontWeight: '800',
|
|
152
|
+
marginBottom: 4,
|
|
153
|
+
},
|
|
154
|
+
subtitle: {
|
|
155
|
+
fontSize: 14,
|
|
156
|
+
marginBottom: 12,
|
|
157
|
+
},
|
|
158
|
+
progressContainer: {
|
|
159
|
+
height: 8,
|
|
160
|
+
borderRadius: 4,
|
|
161
|
+
overflow: 'hidden',
|
|
162
|
+
backgroundColor: 'rgba(0,0,0,0.06)',
|
|
163
|
+
},
|
|
164
|
+
progressBar: {
|
|
165
|
+
height: '100%',
|
|
166
|
+
borderRadius: 4,
|
|
167
|
+
backgroundColor: 'rgba(0,0,0,0.2)',
|
|
168
|
+
},
|
|
169
|
+
macroCard: {
|
|
170
|
+
flex: 1,
|
|
171
|
+
borderRadius: 16,
|
|
172
|
+
overflow: 'hidden',
|
|
173
|
+
borderWidth: 1,
|
|
174
|
+
backgroundColor: '#FFFFFF',
|
|
175
|
+
},
|
|
176
|
+
macroGradient: {
|
|
177
|
+
padding: 16,
|
|
178
|
+
alignItems: 'center',
|
|
179
|
+
},
|
|
180
|
+
macroLabel: {
|
|
181
|
+
fontSize: 12,
|
|
182
|
+
fontWeight: '600',
|
|
183
|
+
marginTop: 8,
|
|
184
|
+
},
|
|
185
|
+
macroValue: {
|
|
186
|
+
fontSize: 22,
|
|
187
|
+
fontWeight: '700',
|
|
188
|
+
marginTop: 4,
|
|
189
|
+
},
|
|
190
|
+
nutrientRow: {
|
|
191
|
+
flexDirection: 'row',
|
|
192
|
+
justifyContent: 'space-between',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
paddingVertical: 12,
|
|
195
|
+
borderBottomWidth: 1,
|
|
196
|
+
},
|
|
197
|
+
nutrientLeft: {
|
|
198
|
+
flexDirection: 'row',
|
|
199
|
+
alignItems: 'center',
|
|
200
|
+
},
|
|
201
|
+
nutrientIcon: {
|
|
202
|
+
marginRight: 12,
|
|
203
|
+
},
|
|
204
|
+
nutrientLabel: {
|
|
205
|
+
fontSize: 15,
|
|
206
|
+
fontWeight: '500',
|
|
207
|
+
},
|
|
208
|
+
nutrientValue: {
|
|
209
|
+
fontSize: 16,
|
|
210
|
+
fontWeight: '700',
|
|
211
|
+
},
|
|
212
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { View, StyleSheet, Animated } from 'react-native';
|
|
3
|
+
import Svg, { Circle, G } from 'react-native-svg';
|
|
4
|
+
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
5
|
+
|
|
6
|
+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
7
|
+
|
|
8
|
+
interface CArcProgressProps {
|
|
9
|
+
size?: number;
|
|
10
|
+
strokeWidth?: number;
|
|
11
|
+
current: number;
|
|
12
|
+
target: number;
|
|
13
|
+
color?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* C-shaped arc progress (270-degree arc with gap at bottom-left).
|
|
18
|
+
* Used as the main calorie ring on the dashboard.
|
|
19
|
+
*/
|
|
20
|
+
export const CArcProgress: React.FC<CArcProgressProps> = ({
|
|
21
|
+
size = 120,
|
|
22
|
+
strokeWidth = 10,
|
|
23
|
+
current,
|
|
24
|
+
target,
|
|
25
|
+
color = '#1A1A1A',
|
|
26
|
+
}) => {
|
|
27
|
+
const animatedValue = useRef(new Animated.Value(0)).current;
|
|
28
|
+
const circleRef = useRef<any>(null);
|
|
29
|
+
|
|
30
|
+
const radius = (size - strokeWidth) / 2;
|
|
31
|
+
const circumference = 2 * Math.PI * radius;
|
|
32
|
+
// 270 degrees = 75% of full circle
|
|
33
|
+
const arcLength = circumference * 0.75;
|
|
34
|
+
const percentage = target > 0 ? Math.min((current / target) * 100, 100) : 0;
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
Animated.timing(animatedValue, {
|
|
38
|
+
toValue: percentage,
|
|
39
|
+
duration: 1000,
|
|
40
|
+
useNativeDriver: true,
|
|
41
|
+
}).start();
|
|
42
|
+
|
|
43
|
+
const listener = animatedValue.addListener((v) => {
|
|
44
|
+
if (circleRef.current) {
|
|
45
|
+
const progressLength = (arcLength * v.value) / 100;
|
|
46
|
+
const dashOffset = arcLength - progressLength;
|
|
47
|
+
circleRef.current.setNativeProps({ strokeDashoffset: dashOffset });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
animatedValue.removeListener(listener);
|
|
53
|
+
};
|
|
54
|
+
}, [percentage]);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View style={[styles.container, { width: size, height: size }]}>
|
|
58
|
+
<Svg width={size} height={size}>
|
|
59
|
+
{/* Rotate so the gap is at bottom-left: start from 135deg (7.5 o'clock) */}
|
|
60
|
+
<G rotation="135" origin={`${size / 2}, ${size / 2}`}>
|
|
61
|
+
{/* Background arc */}
|
|
62
|
+
<Circle
|
|
63
|
+
cx={size / 2}
|
|
64
|
+
cy={size / 2}
|
|
65
|
+
r={radius}
|
|
66
|
+
stroke="rgba(0,0,0,0.08)"
|
|
67
|
+
strokeWidth={strokeWidth}
|
|
68
|
+
fill="transparent"
|
|
69
|
+
strokeDasharray={`${arcLength} ${circumference - arcLength}`}
|
|
70
|
+
strokeLinecap="round"
|
|
71
|
+
/>
|
|
72
|
+
{/* Progress arc — hidden when current=0 to avoid round-cap artifact */}
|
|
73
|
+
{current > 0 && (
|
|
74
|
+
<AnimatedCircle
|
|
75
|
+
ref={circleRef}
|
|
76
|
+
cx={size / 2}
|
|
77
|
+
cy={size / 2}
|
|
78
|
+
r={radius}
|
|
79
|
+
stroke={color}
|
|
80
|
+
strokeWidth={strokeWidth}
|
|
81
|
+
fill="transparent"
|
|
82
|
+
strokeDasharray={`${arcLength} ${circumference}`}
|
|
83
|
+
strokeDashoffset={arcLength}
|
|
84
|
+
strokeLinecap="round"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
</G>
|
|
88
|
+
</Svg>
|
|
89
|
+
{/* Center icon */}
|
|
90
|
+
<View style={styles.centerIcon}>
|
|
91
|
+
<Icon name="fire" size={size * 0.22} color="#FF6B35" />
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const styles = StyleSheet.create({
|
|
98
|
+
container: {
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
},
|
|
102
|
+
centerIcon: {
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
},
|
|
107
|
+
});
|