@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 ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@klyftig/mobile-ui",
3
+ "version": "1.0.2",
4
+ "scripts": {
5
+ "pack-install": "npm pack && mv klyftig-ui-*.tgz /tmp/klyftig-ui-local.tgz && cd ../../mobile && npm install /tmp/klyftig-ui-local.tgz"
6
+ },
7
+ "description": "Klyftig shared UI components for React Native / Expo",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "keywords": [
14
+ "react-native",
15
+ "expo",
16
+ "ui",
17
+ "components"
18
+ ],
19
+ "license": "MIT",
20
+ "peerDependencies": {
21
+ "react": ">=19.0.0",
22
+ "react-native": ">=0.83.0",
23
+ "expo-glass-effect": "*",
24
+ "expo-linear-gradient": "*",
25
+ "@expo/vector-icons": "*",
26
+ "react-native-svg": "*",
27
+ "react-native-safe-area-context": "*"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { View, type ViewStyle, type StyleProp } from 'react-native';
3
+ import { GlassView } from 'expo-glass-effect';
4
+ import { GLASS_AVAILABLE } from '../utils/glass';
5
+
6
+ interface AdaptiveGlassViewProps {
7
+ children?: React.ReactNode;
8
+ /** Glass material variant: 'regular' (default) or 'clear' (more transparent) */
9
+ variant?: 'regular' | 'clear';
10
+ /** Tint color applied to the glass material */
11
+ tintColor?: string;
12
+ /** Whether the glass responds to touch interactions */
13
+ interactive?: boolean;
14
+ /** Style applied when glass is NOT available (fallback) */
15
+ fallbackStyle?: StyleProp<ViewStyle>;
16
+ /** Style applied in both glass and fallback modes */
17
+ style?: StyleProp<ViewStyle>;
18
+ }
19
+
20
+ /**
21
+ * Single DRY wrapper for Liquid Glass on iOS 26+ with graceful fallback.
22
+ * This is the ONLY place glass/non-glass branching happens in the app.
23
+ */
24
+ export const AdaptiveGlassView: React.FC<AdaptiveGlassViewProps> = ({
25
+ children,
26
+ variant = 'regular',
27
+ tintColor,
28
+ interactive = false,
29
+ fallbackStyle,
30
+ style,
31
+ }) => {
32
+ if (GLASS_AVAILABLE) {
33
+ return (
34
+ <GlassView
35
+ glassEffectStyle={variant}
36
+ tintColor={tintColor}
37
+ isInteractive={interactive}
38
+ style={style}
39
+ >
40
+ {children}
41
+ </GlassView>
42
+ );
43
+ }
44
+
45
+ return <View style={[fallbackStyle, style]}>{children}</View>;
46
+ };
@@ -0,0 +1,92 @@
1
+ import React, { Component, ErrorInfo, ReactNode } from 'react';
2
+ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3
+ import { colors } from '../theme/colors';
4
+
5
+ interface Props {
6
+ children: ReactNode;
7
+ }
8
+
9
+ interface State {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
13
+
14
+ export class ErrorBoundary extends Component<Props, State> {
15
+ constructor(props: Props) {
16
+ super(props);
17
+ this.state = { hasError: false, error: null };
18
+ }
19
+
20
+ static getDerivedStateFromError(error: Error): State {
21
+ return { hasError: true, error };
22
+ }
23
+
24
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
25
+ console.error('[ErrorBoundary] Uncaught error:', error);
26
+ console.error('[ErrorBoundary] Component stack:', errorInfo.componentStack);
27
+ }
28
+
29
+ private handleReset = (): void => {
30
+ this.setState({ hasError: false, error: null });
31
+ };
32
+
33
+ render(): ReactNode {
34
+ if (!this.state.hasError) {
35
+ return this.props.children;
36
+ }
37
+
38
+ return (
39
+ <View style={styles.container}>
40
+ <Text style={styles.emoji}>!</Text>
41
+ <Text style={styles.title}>Something went wrong</Text>
42
+ <Text style={styles.message}>
43
+ An unexpected error occurred. Please try again.
44
+ </Text>
45
+ <TouchableOpacity style={styles.button} onPress={this.handleReset}>
46
+ <Text style={styles.buttonText}>Try Again</Text>
47
+ </TouchableOpacity>
48
+ </View>
49
+ );
50
+ }
51
+ }
52
+
53
+ const styles = StyleSheet.create({
54
+ container: {
55
+ flex: 1,
56
+ justifyContent: 'center',
57
+ alignItems: 'center',
58
+ backgroundColor: '#F5F4EF',
59
+ paddingHorizontal: 32,
60
+ },
61
+ emoji: {
62
+ fontSize: 48,
63
+ fontWeight: '700',
64
+ color: colors.accent.error,
65
+ marginBottom: 16,
66
+ },
67
+ title: {
68
+ fontSize: 22,
69
+ fontWeight: '700',
70
+ color: colors.text.primary,
71
+ marginBottom: 8,
72
+ textAlign: 'center',
73
+ },
74
+ message: {
75
+ fontSize: 15,
76
+ color: colors.text.secondary,
77
+ textAlign: 'center',
78
+ lineHeight: 22,
79
+ marginBottom: 32,
80
+ },
81
+ button: {
82
+ backgroundColor: colors.accent.brand,
83
+ paddingHorizontal: 32,
84
+ paddingVertical: 14,
85
+ borderRadius: 12,
86
+ },
87
+ buttonText: {
88
+ fontSize: 16,
89
+ fontWeight: '600',
90
+ color: '#FFFFFF',
91
+ },
92
+ });
@@ -0,0 +1,209 @@
1
+ import React, { useState, useRef, useCallback } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ Animated,
8
+ } from 'react-native';
9
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
10
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
11
+ import { AdaptiveGlassView } from './AdaptiveGlassView';
12
+ import { GLASS_AVAILABLE } from '../utils/glass';
13
+ import { colors } from '../theme/colors';
14
+
15
+ interface FABAction {
16
+ icon: string;
17
+ label: string;
18
+ onPress: () => void;
19
+ color?: string;
20
+ }
21
+
22
+ interface FABProps {
23
+ actions: FABAction[];
24
+ /** Override main button icon (e.g. 'camera' when input is focused) */
25
+ overrideIcon?: string;
26
+ /** If set, tapping the main button calls this directly instead of toggling the menu */
27
+ overrideOnPress?: () => void;
28
+ }
29
+
30
+ export const FAB_SIZE = 50;
31
+ const SUB_BUTTON_SIZE = 44;
32
+ const SUB_BUTTON_SPACING = 58;
33
+
34
+ // Fallback colors for sub-buttons when no color is specified
35
+ const ACTION_COLORS = ['#10b981', '#f59e0b', '#0ea5e9', '#8b5cf6', '#ec4899'];
36
+
37
+ export const FAB: React.FC<FABProps> = ({ actions, overrideIcon, overrideOnPress }) => {
38
+ const insets = useSafeAreaInsets();
39
+ const [isExpanded, setIsExpanded] = useState(false);
40
+ const expandAnim = useRef(new Animated.Value(0)).current;
41
+
42
+ const toggle = useCallback(() => {
43
+ const toValue = isExpanded ? 0 : 1;
44
+ Animated.spring(expandAnim, {
45
+ toValue,
46
+ tension: 60,
47
+ friction: 8,
48
+ useNativeDriver: true,
49
+ }).start();
50
+ setIsExpanded(!isExpanded);
51
+ }, [isExpanded, expandAnim]);
52
+
53
+ const handleAction = useCallback((action: FABAction) => {
54
+ toggle();
55
+ setTimeout(action.onPress, 200);
56
+ }, [toggle]);
57
+
58
+ const subButtonOpacity = expandAnim.interpolate({
59
+ inputRange: [0, 0.5, 1],
60
+ outputRange: [0, 0.5, 1],
61
+ });
62
+
63
+ const subButtonScale = expandAnim.interpolate({
64
+ inputRange: [0, 0.5, 1],
65
+ outputRange: [0.3, 0.8, 1],
66
+ });
67
+
68
+ const fabBottom = Math.max(insets.bottom, 16) + 8;
69
+
70
+ return (
71
+ <>
72
+ {/* Backdrop */}
73
+ {isExpanded && (
74
+ <TouchableOpacity
75
+ style={styles.backdrop}
76
+ activeOpacity={1}
77
+ onPress={toggle}
78
+ accessibilityLabel="Close menu"
79
+ accessibilityRole="button"
80
+ />
81
+ )}
82
+
83
+ {/* Sub-action buttons — fly upward from FAB */}
84
+ {isExpanded && actions.map((action, index) => {
85
+ const translateY = expandAnim.interpolate({
86
+ inputRange: [0, 1],
87
+ outputRange: [0, -(SUB_BUTTON_SPACING * (index + 1))],
88
+ });
89
+
90
+ return (
91
+ <Animated.View
92
+ key={index}
93
+ style={[
94
+ styles.subButtonRow,
95
+ {
96
+ bottom: fabBottom + (FAB_SIZE - SUB_BUTTON_SIZE) / 2,
97
+ right: 16 + (FAB_SIZE - SUB_BUTTON_SIZE) / 2,
98
+ transform: [{ translateY }, { scale: subButtonScale }],
99
+ opacity: subButtonOpacity,
100
+ },
101
+ ]}
102
+ >
103
+ <TouchableOpacity
104
+ style={styles.labelPill}
105
+ onPress={() => handleAction(action)}
106
+ activeOpacity={0.7}
107
+ accessibilityRole="button"
108
+ accessibilityLabel={action.label}
109
+ >
110
+ <Text style={styles.labelText}>{action.label}</Text>
111
+ </TouchableOpacity>
112
+ <TouchableOpacity
113
+ style={[styles.subButton, { backgroundColor: action.color ?? ACTION_COLORS[index % ACTION_COLORS.length] }]}
114
+ onPress={() => handleAction(action)}
115
+ activeOpacity={0.8}
116
+ accessibilityRole="button"
117
+ accessibilityLabel={action.label}
118
+ >
119
+ <Icon name={action.icon as any} size={22} color="#fff" />
120
+ </TouchableOpacity>
121
+ </Animated.View>
122
+ );
123
+ })}
124
+
125
+ {/* Main FAB button */}
126
+ <AdaptiveGlassView
127
+ variant="clear"
128
+ interactive={true}
129
+ style={[styles.fabGlass, { bottom: fabBottom, right: 16 }]}
130
+ fallbackStyle={styles.fabFallback}
131
+ >
132
+ <TouchableOpacity
133
+ style={styles.fabTouchable}
134
+ onPress={overrideOnPress ?? toggle}
135
+ activeOpacity={0.85}
136
+ accessibilityRole="button"
137
+ accessibilityLabel={overrideOnPress ? 'Capture food' : (isExpanded ? 'Close actions menu' : 'Open actions menu')}
138
+ accessibilityState={{ expanded: overrideOnPress ? undefined : isExpanded }}
139
+ >
140
+ <Icon
141
+ name={overrideIcon ?? (isExpanded ? 'close' : 'plus')}
142
+ size={24}
143
+ color={GLASS_AVAILABLE ? '#1A1A1A' : '#FFFFFF'}
144
+ />
145
+ </TouchableOpacity>
146
+ </AdaptiveGlassView>
147
+ </>
148
+ );
149
+ };
150
+
151
+ const styles = StyleSheet.create({
152
+ fabGlass: {
153
+ position: 'absolute',
154
+ width: FAB_SIZE,
155
+ height: FAB_SIZE,
156
+ borderRadius: FAB_SIZE / 2,
157
+ zIndex: 100,
158
+ },
159
+ fabFallback: {
160
+ backgroundColor: colors.glass.fabFallback,
161
+ elevation: 8,
162
+ shadowColor: '#000',
163
+ shadowOffset: { width: 0, height: 4 },
164
+ shadowOpacity: 0.15,
165
+ shadowRadius: 12,
166
+ },
167
+ fabTouchable: {
168
+ width: '100%',
169
+ height: '100%',
170
+ borderRadius: FAB_SIZE / 2,
171
+ justifyContent: 'center',
172
+ alignItems: 'center',
173
+ },
174
+ backdrop: {
175
+ ...StyleSheet.absoluteFillObject,
176
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
177
+ zIndex: 99,
178
+ },
179
+ subButtonRow: {
180
+ position: 'absolute',
181
+ flexDirection: 'row',
182
+ alignItems: 'center',
183
+ zIndex: 100,
184
+ },
185
+ labelPill: {
186
+ backgroundColor: 'rgba(0,0,0,0.75)',
187
+ paddingHorizontal: 12,
188
+ paddingVertical: 7,
189
+ borderRadius: 8,
190
+ marginRight: 10,
191
+ },
192
+ labelText: {
193
+ color: '#fff',
194
+ fontSize: 13,
195
+ fontWeight: '600',
196
+ },
197
+ subButton: {
198
+ width: SUB_BUTTON_SIZE,
199
+ height: SUB_BUTTON_SIZE,
200
+ borderRadius: SUB_BUTTON_SIZE / 2,
201
+ justifyContent: 'center',
202
+ alignItems: 'center',
203
+ shadowColor: '#000',
204
+ shadowOffset: { width: 0, height: 2 },
205
+ shadowOpacity: 0.25,
206
+ shadowRadius: 4,
207
+ elevation: 5,
208
+ },
209
+ });
@@ -0,0 +1,279 @@
1
+ import React, { useRef } from 'react';
2
+ import { TouchableOpacity, Text, View, StyleSheet, ActivityIndicator, Animated } from 'react-native';
3
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+
5
+ interface GlassButtonProps {
6
+ title: string;
7
+ onPress: () => void;
8
+ variant?: 'primary' | 'secondary' | 'text' | 'danger';
9
+ loading?: boolean;
10
+ disabled?: boolean;
11
+ icon?: string;
12
+ fullWidth?: boolean;
13
+ size?: 'normal' | 'compact';
14
+ }
15
+
16
+ export const GlassButton: React.FC<GlassButtonProps> = ({
17
+ title,
18
+ onPress,
19
+ variant = 'primary',
20
+ loading = false,
21
+ disabled = false,
22
+ icon,
23
+ fullWidth = true,
24
+ size = 'normal',
25
+ }) => {
26
+ const isDisabled = disabled || loading;
27
+ const isCompact = size === 'compact';
28
+ const scaleAnim = useRef(new Animated.Value(1)).current;
29
+
30
+ const handlePressIn = () => {
31
+ Animated.spring(scaleAnim, {
32
+ toValue: 0.97,
33
+ speed: 50,
34
+ bounciness: 4,
35
+ useNativeDriver: true,
36
+ }).start();
37
+ };
38
+
39
+ const handlePressOut = () => {
40
+ Animated.spring(scaleAnim, {
41
+ toValue: 1,
42
+ speed: 50,
43
+ bounciness: 4,
44
+ useNativeDriver: true,
45
+ }).start();
46
+ };
47
+
48
+ if (variant === 'text') {
49
+ return (
50
+ <TouchableOpacity
51
+ onPress={onPress}
52
+ disabled={isDisabled}
53
+ accessibilityRole="button"
54
+ accessibilityLabel={title}
55
+ accessibilityState={{ disabled: isDisabled }}
56
+ style={[
57
+ styles.textButton,
58
+ !fullWidth && styles.textButtonNotFull,
59
+ isCompact && styles.textButtonCompact,
60
+ ]}
61
+ >
62
+ <Text style={[styles.textButtonText, isCompact && styles.textButtonTextCompact]}>{title}</Text>
63
+ </TouchableOpacity>
64
+ );
65
+ }
66
+
67
+ if (variant === 'primary') {
68
+ return (
69
+ <TouchableOpacity
70
+ onPress={onPress}
71
+ disabled={isDisabled}
72
+ activeOpacity={1}
73
+ onPressIn={handlePressIn}
74
+ onPressOut={handlePressOut}
75
+ accessibilityRole="button"
76
+ accessibilityLabel={title}
77
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
78
+ style={[!fullWidth && styles.buttonNotFull]}
79
+ >
80
+ <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
81
+ <View
82
+ style={[
83
+ styles.primaryButton,
84
+ isDisabled && styles.primaryButtonDisabled,
85
+ isCompact && styles.primaryButtonCompact,
86
+ ]}
87
+ >
88
+ {loading ? (
89
+ <ActivityIndicator color="#FFFFFF" size={isCompact ? 'small' : 'large'} />
90
+ ) : (
91
+ <>
92
+ {icon && <Icon name={icon} size={isCompact ? 16 : 20} color="#FFFFFF" style={styles.buttonIcon} />}
93
+ <Text style={[styles.primaryButtonText, isCompact && styles.primaryButtonTextCompact]}>{title}</Text>
94
+ </>
95
+ )}
96
+ </View>
97
+ </Animated.View>
98
+ </TouchableOpacity>
99
+ );
100
+ }
101
+
102
+ if (variant === 'danger') {
103
+ return (
104
+ <TouchableOpacity
105
+ onPress={onPress}
106
+ disabled={isDisabled}
107
+ activeOpacity={1}
108
+ onPressIn={handlePressIn}
109
+ onPressOut={handlePressOut}
110
+ accessibilityRole="button"
111
+ accessibilityLabel={title}
112
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
113
+ style={[!fullWidth && styles.buttonNotFull]}
114
+ >
115
+ <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
116
+ <View
117
+ style={[
118
+ styles.dangerButton,
119
+ isDisabled && styles.primaryButtonDisabled,
120
+ isCompact && styles.primaryButtonCompact,
121
+ ]}
122
+ >
123
+ {loading ? (
124
+ <ActivityIndicator color="#fff" size={isCompact ? 'small' : 'large'} />
125
+ ) : (
126
+ <>
127
+ {icon && <Icon name={icon} size={isCompact ? 16 : 20} color="#fff" style={styles.buttonIcon} />}
128
+ <Text style={[styles.dangerButtonText, isCompact && styles.primaryButtonTextCompact]}>{title}</Text>
129
+ </>
130
+ )}
131
+ </View>
132
+ </Animated.View>
133
+ </TouchableOpacity>
134
+ );
135
+ }
136
+
137
+ // Secondary variant
138
+ return (
139
+ <TouchableOpacity
140
+ onPress={onPress}
141
+ disabled={isDisabled}
142
+ activeOpacity={1}
143
+ onPressIn={handlePressIn}
144
+ onPressOut={handlePressOut}
145
+ accessibilityRole="button"
146
+ accessibilityLabel={title}
147
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
148
+ style={[!fullWidth && styles.buttonNotFull]}
149
+ >
150
+ <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
151
+ <View
152
+ style={[
153
+ styles.secondaryButton,
154
+ isDisabled && styles.primaryButtonDisabled,
155
+ isCompact && styles.secondaryButtonCompact,
156
+ ]}
157
+ >
158
+ {loading ? (
159
+ <ActivityIndicator color="#1A1A1A" size={isCompact ? 'small' : 'large'} />
160
+ ) : (
161
+ <>
162
+ {icon && <Icon name={icon} size={isCompact ? 16 : 20} color="#1A1A1A" style={styles.buttonIcon} />}
163
+ <Text style={[styles.secondaryButtonText, isCompact && styles.secondaryButtonTextCompact]}>{title}</Text>
164
+ </>
165
+ )}
166
+ </View>
167
+ </Animated.View>
168
+ </TouchableOpacity>
169
+ );
170
+ };
171
+
172
+ const styles = StyleSheet.create({
173
+ primaryButton: {
174
+ height: 56,
175
+ borderRadius: 28,
176
+ flexDirection: 'row',
177
+ alignItems: 'center',
178
+ justifyContent: 'center',
179
+ backgroundColor: '#1A1A1A',
180
+ borderWidth: 1,
181
+ borderColor: 'rgba(255,255,255,0.15)',
182
+ shadowColor: '#000',
183
+ shadowOffset: { width: 0, height: 6 },
184
+ shadowOpacity: 0.2,
185
+ shadowRadius: 16,
186
+ elevation: 6,
187
+ },
188
+ primaryButtonCompact: {
189
+ height: 40,
190
+ borderRadius: 10,
191
+ paddingHorizontal: 20,
192
+ },
193
+ primaryButtonDisabled: {
194
+ opacity: 0.5,
195
+ },
196
+ primaryButtonText: {
197
+ fontSize: 16,
198
+ fontWeight: '700',
199
+ letterSpacing: 0.5,
200
+ color: '#FFFFFF',
201
+ },
202
+ primaryButtonTextCompact: {
203
+ fontSize: 14,
204
+ fontWeight: '600',
205
+ },
206
+ dangerButton: {
207
+ height: 56,
208
+ borderRadius: 16,
209
+ flexDirection: 'row',
210
+ alignItems: 'center',
211
+ justifyContent: 'center',
212
+ backgroundColor: '#F44336',
213
+ shadowColor: '#000',
214
+ shadowOffset: { width: 0, height: 2 },
215
+ shadowOpacity: 0.15,
216
+ shadowRadius: 6,
217
+ elevation: 4,
218
+ },
219
+ dangerButtonText: {
220
+ fontSize: 16,
221
+ fontWeight: '700',
222
+ color: '#fff',
223
+ letterSpacing: 0.5,
224
+ },
225
+ secondaryButton: {
226
+ height: 56,
227
+ borderRadius: 16,
228
+ flexDirection: 'row',
229
+ alignItems: 'center',
230
+ justifyContent: 'center',
231
+ backgroundColor: 'rgba(255,255,255,0.85)',
232
+ borderWidth: 1.5,
233
+ borderColor: 'rgba(0,0,0,0.10)',
234
+ shadowColor: '#000',
235
+ shadowOffset: { width: 0, height: 2 },
236
+ shadowOpacity: 0.06,
237
+ shadowRadius: 8,
238
+ elevation: 2,
239
+ },
240
+ secondaryButtonCompact: {
241
+ height: 40,
242
+ borderRadius: 10,
243
+ paddingHorizontal: 20,
244
+ },
245
+ secondaryButtonText: {
246
+ fontSize: 16,
247
+ fontWeight: '600',
248
+ letterSpacing: 0.5,
249
+ color: '#1A1A1A',
250
+ },
251
+ secondaryButtonTextCompact: {
252
+ fontSize: 14,
253
+ fontWeight: '600',
254
+ },
255
+ buttonNotFull: {
256
+ alignSelf: 'center',
257
+ },
258
+ buttonIcon: {
259
+ marginRight: 8,
260
+ },
261
+ textButton: {
262
+ paddingVertical: 12,
263
+ alignItems: 'center',
264
+ },
265
+ textButtonCompact: {
266
+ paddingVertical: 8,
267
+ },
268
+ textButtonNotFull: {
269
+ alignSelf: 'center',
270
+ },
271
+ textButtonText: {
272
+ fontSize: 15,
273
+ fontWeight: '600',
274
+ color: '#6B6B6B',
275
+ },
276
+ textButtonTextCompact: {
277
+ fontSize: 14,
278
+ },
279
+ });