@klyftig/mobile-ui 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klyftig/mobile-ui",
3
- "version": "1.0.4",
3
+ "version": "1.1.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
  },
@@ -18,6 +18,7 @@
18
18
  ],
19
19
  "license": "MIT",
20
20
  "peerDependencies": {
21
+ "expo": "*",
21
22
  "react": ">=19.0.0",
22
23
  "react-native": ">=0.83.0",
23
24
  "expo-glass-effect": "*",
@@ -29,7 +30,7 @@
29
30
  "publishConfig": {
30
31
  "access": "public"
31
32
  },
32
- "dependencies": {
33
+ "devDependencies": {
33
34
  "expo": "~55.0.11",
34
35
  "react": "19.2.0",
35
36
  "react-native": "0.83.4"
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+ import { useTheme } from '../hooks/useTheme';
5
+
6
+ export interface EmptyStateProps {
7
+ /** MaterialCommunityIcons name. Defaults to "inbox-outline". */
8
+ icon?: string;
9
+ iconSize?: number;
10
+ iconColor?: string;
11
+ title: string;
12
+ description?: string;
13
+ /** Render an action button (or any node) below the description. */
14
+ action?: React.ReactNode;
15
+ style?: StyleProp<ViewStyle>;
16
+ }
17
+
18
+ /**
19
+ * Large-icon empty state used on list/feed screens with no data.
20
+ * Centered in its parent; pass a flex-1 wrapper to vertically center on a full screen.
21
+ */
22
+ export function EmptyState({
23
+ icon = 'inbox-outline',
24
+ iconSize = 64,
25
+ iconColor,
26
+ title,
27
+ description,
28
+ action,
29
+ style,
30
+ }: EmptyStateProps) {
31
+ const { colors } = useTheme();
32
+ const resolvedIconColor = iconColor ?? colors.icon.tertiary;
33
+ return (
34
+ <View style={[styles.container, style]}>
35
+ <Icon name={icon as any} size={iconSize} color={resolvedIconColor} />
36
+ <Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
37
+ {description && (
38
+ <Text style={[styles.description, { color: colors.text.tertiary }]}>
39
+ {description}
40
+ </Text>
41
+ )}
42
+ {action && <View style={styles.action}>{action}</View>}
43
+ </View>
44
+ );
45
+ }
46
+
47
+ const styles = StyleSheet.create({
48
+ container: {
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ padding: 40,
52
+ },
53
+ title: {
54
+ fontSize: 18,
55
+ fontWeight: '700',
56
+ marginTop: 16,
57
+ marginBottom: 8,
58
+ textAlign: 'center',
59
+ },
60
+ description: {
61
+ fontSize: 14,
62
+ fontWeight: '500',
63
+ textAlign: 'center',
64
+ },
65
+ action: {
66
+ marginTop: 20,
67
+ },
68
+ });
@@ -0,0 +1,81 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ StyleProp,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import { useTheme } from '../hooks/useTheme';
11
+
12
+ export interface ErrorStateProps {
13
+ title?: string;
14
+ description?: string;
15
+ retryLabel?: string;
16
+ onRetry?: () => void;
17
+ style?: StyleProp<ViewStyle>;
18
+ }
19
+
20
+ /**
21
+ * Centered error view with optional retry button.
22
+ * Use inside a flex-1 wrapper to vertically center on a full screen.
23
+ */
24
+ export function ErrorState({
25
+ title = 'Something went wrong',
26
+ description,
27
+ retryLabel = 'Try Again',
28
+ onRetry,
29
+ style,
30
+ }: ErrorStateProps) {
31
+ const { colors } = useTheme();
32
+ return (
33
+ <View style={[styles.container, style]}>
34
+ <Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
35
+ {description && (
36
+ <Text style={[styles.description, { color: colors.text.secondary }]}>
37
+ {description}
38
+ </Text>
39
+ )}
40
+ {onRetry && (
41
+ <TouchableOpacity
42
+ onPress={onRetry}
43
+ accessibilityRole="button"
44
+ accessibilityLabel={retryLabel}
45
+ style={[styles.retryButton, { backgroundColor: colors.text.primary }]}
46
+ >
47
+ <Text style={styles.retryText}>{retryLabel}</Text>
48
+ </TouchableOpacity>
49
+ )}
50
+ </View>
51
+ );
52
+ }
53
+
54
+ const styles = StyleSheet.create({
55
+ container: {
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ padding: 40,
59
+ },
60
+ title: {
61
+ fontSize: 18,
62
+ fontWeight: '700',
63
+ marginBottom: 8,
64
+ textAlign: 'center',
65
+ },
66
+ description: {
67
+ fontSize: 14,
68
+ textAlign: 'center',
69
+ marginBottom: 20,
70
+ },
71
+ retryButton: {
72
+ paddingHorizontal: 24,
73
+ paddingVertical: 10,
74
+ borderRadius: 10,
75
+ },
76
+ retryText: {
77
+ color: '#FFFFFF',
78
+ fontWeight: '600',
79
+ fontSize: 14,
80
+ },
81
+ });
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import { View, Text, ActivityIndicator, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
+ import { useTheme } from '../hooks/useTheme';
4
+
5
+ export interface LoadingStateProps {
6
+ message?: string;
7
+ size?: 'small' | 'large';
8
+ color?: string;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /**
13
+ * Centered ActivityIndicator + optional message. Default uses the brand text color.
14
+ */
15
+ export function LoadingState({
16
+ message,
17
+ size = 'large',
18
+ color,
19
+ style,
20
+ }: LoadingStateProps) {
21
+ const { colors } = useTheme();
22
+ const resolvedColor = color ?? colors.text.primary;
23
+ return (
24
+ <View style={[styles.container, style]}>
25
+ <ActivityIndicator size={size} color={resolvedColor} />
26
+ {message && (
27
+ <Text style={[styles.message, { color: colors.text.secondary }]}>{message}</Text>
28
+ )}
29
+ </View>
30
+ );
31
+ }
32
+
33
+ const styles = StyleSheet.create({
34
+ container: {
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ padding: 40,
38
+ },
39
+ message: {
40
+ fontSize: 14,
41
+ fontWeight: '500',
42
+ marginTop: 12,
43
+ textAlign: 'center',
44
+ },
45
+ });
@@ -0,0 +1,190 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import {
3
+ TouchableOpacity,
4
+ View,
5
+ Text,
6
+ StyleSheet,
7
+ StyleProp,
8
+ ViewStyle,
9
+ TextStyle,
10
+ } from 'react-native';
11
+ import { useTheme } from '../hooks/useTheme';
12
+
13
+ type RadioGroupContextValue<T> = {
14
+ value: T | undefined;
15
+ onChange: (value: T) => void;
16
+ };
17
+
18
+ const RadioGroupContext = createContext<RadioGroupContextValue<unknown> | null>(null);
19
+
20
+ export interface RadioGroupProps<T> {
21
+ value: T | undefined;
22
+ onChange: (value: T) => void;
23
+ children: React.ReactNode;
24
+ style?: StyleProp<ViewStyle>;
25
+ accessibilityLabel?: string;
26
+ }
27
+
28
+ export function RadioGroup<T>({ value, onChange, children, style, accessibilityLabel }: RadioGroupProps<T>) {
29
+ return (
30
+ <RadioGroupContext.Provider value={{ value, onChange: onChange as (v: unknown) => void }}>
31
+ <View
32
+ accessibilityRole="radiogroup"
33
+ accessibilityLabel={accessibilityLabel}
34
+ style={style}
35
+ >
36
+ {children}
37
+ </View>
38
+ </RadioGroupContext.Provider>
39
+ );
40
+ }
41
+
42
+ export interface RadioOptionProps<T = string> {
43
+ /** The value this option represents — used for selection matching when inside a RadioGroup. */
44
+ value?: T;
45
+ /** Explicit selected flag. Ignored if rendered inside a RadioGroup with a matching `value`. */
46
+ selected?: boolean;
47
+ /** Callback when the option is tapped. Ignored if rendered inside a RadioGroup (the group's onChange fires). */
48
+ onPress?: () => void;
49
+ /** Primary label text. Either `label` or `children` is required. */
50
+ label?: string;
51
+ /** Secondary description text shown under the label. */
52
+ description?: string;
53
+ /** Render custom content to the right of the radio circle instead of label/description. */
54
+ children?: React.ReactNode;
55
+ /** `"sm"` for a compact inline row (gender pickers); `"md"` for a card-style row (activity level). */
56
+ variant?: 'sm' | 'md';
57
+ style?: StyleProp<ViewStyle>;
58
+ labelStyle?: StyleProp<TextStyle>;
59
+ disabled?: boolean;
60
+ }
61
+
62
+ /**
63
+ * A tap-anywhere radio row: the circle is purely visual (pointerEvents: none)
64
+ * and the entire row is a single TouchableOpacity. Safe inside ScrollViews
65
+ * and dialogs — no competing gesture responders.
66
+ *
67
+ * Use inside <RadioGroup value={x} onChange={setX}> for controlled selection,
68
+ * or pass `selected` + `onPress` directly for standalone use.
69
+ */
70
+ export function RadioOption<T = string>({
71
+ value,
72
+ selected,
73
+ onPress,
74
+ label,
75
+ description,
76
+ children,
77
+ variant = 'sm',
78
+ style,
79
+ labelStyle,
80
+ disabled = false,
81
+ }: RadioOptionProps<T>) {
82
+ const { colors } = useTheme();
83
+ const group = useContext(RadioGroupContext) as RadioGroupContextValue<T> | null;
84
+
85
+ const isSelected = group && value !== undefined ? group.value === value : !!selected;
86
+ const handlePress = () => {
87
+ if (disabled) return;
88
+ if (group && value !== undefined) {
89
+ group.onChange(value);
90
+ } else {
91
+ onPress?.();
92
+ }
93
+ };
94
+
95
+ const isCard = variant === 'md';
96
+ const ringColor = isSelected ? colors.text.primary : colors.text.tertiary;
97
+
98
+ return (
99
+ <TouchableOpacity
100
+ onPress={handlePress}
101
+ disabled={disabled}
102
+ activeOpacity={0.7}
103
+ accessibilityRole="radio"
104
+ accessibilityState={{ selected: isSelected, disabled }}
105
+ accessibilityLabel={label}
106
+ style={[
107
+ isCard ? styles.cardRow : styles.inlineRow,
108
+ disabled && styles.disabled,
109
+ style,
110
+ ]}
111
+ >
112
+ {/* Radio circle — fully non-interactive; outer TouchableOpacity handles taps. */}
113
+ <View pointerEvents="none" style={[styles.ring, { borderColor: ringColor }]}>
114
+ {isSelected && <View style={[styles.dot, { backgroundColor: colors.text.primary }]} />}
115
+ </View>
116
+
117
+ {children ?? (
118
+ <View style={styles.textContainer}>
119
+ {label && (
120
+ <Text
121
+ style={[
122
+ isCard ? styles.cardLabel : styles.inlineLabel,
123
+ { color: colors.text.primary },
124
+ labelStyle,
125
+ ]}
126
+ >
127
+ {label}
128
+ </Text>
129
+ )}
130
+ {description && (
131
+ <Text style={[styles.description, { color: colors.text.secondary }]}>
132
+ {description}
133
+ </Text>
134
+ )}
135
+ </View>
136
+ )}
137
+ </TouchableOpacity>
138
+ );
139
+ }
140
+
141
+ const styles = StyleSheet.create({
142
+ inlineRow: {
143
+ flexDirection: 'row',
144
+ alignItems: 'center',
145
+ paddingVertical: 6,
146
+ paddingHorizontal: 4,
147
+ },
148
+ cardRow: {
149
+ flexDirection: 'row',
150
+ alignItems: 'center',
151
+ backgroundColor: 'rgba(255,255,255,0.3)',
152
+ borderRadius: 12,
153
+ padding: 12,
154
+ borderWidth: 1,
155
+ borderColor: 'rgba(255,255,255,0.4)',
156
+ },
157
+ ring: {
158
+ width: 20,
159
+ height: 20,
160
+ borderRadius: 10,
161
+ borderWidth: 2,
162
+ alignItems: 'center',
163
+ justifyContent: 'center',
164
+ },
165
+ dot: {
166
+ width: 10,
167
+ height: 10,
168
+ borderRadius: 5,
169
+ },
170
+ textContainer: {
171
+ flex: 1,
172
+ marginLeft: 12,
173
+ },
174
+ inlineLabel: {
175
+ fontSize: 15,
176
+ fontWeight: '500',
177
+ },
178
+ cardLabel: {
179
+ fontSize: 15,
180
+ fontWeight: '600',
181
+ marginBottom: 2,
182
+ },
183
+ description: {
184
+ fontSize: 13,
185
+ fontWeight: '500',
186
+ },
187
+ disabled: {
188
+ opacity: 0.5,
189
+ },
190
+ });
@@ -0,0 +1,107 @@
1
+ import React from 'react';
2
+ import { View, Text, TouchableOpacity, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { useTheme } from '../hooks/useTheme';
6
+
7
+ export interface ScreenHeaderProps {
8
+ title: string;
9
+ subtitle?: string;
10
+ /** Left icon name (MaterialCommunityIcons). Defaults to "home-outline". */
11
+ leftIcon?: string;
12
+ /** Accessibility label for the left button. Defaults to "Go to Dashboard". */
13
+ leftAccessibilityLabel?: string;
14
+ onLeftPress?: () => void;
15
+ /** Right-side content (optional action, e.g. a settings button). Reserves space for symmetry when absent. */
16
+ right?: React.ReactNode;
17
+ /** Render below the title row — e.g. a tab bar or period toggle that should stay pinned with the header. */
18
+ below?: React.ReactNode;
19
+ style?: StyleProp<ViewStyle>;
20
+ }
21
+
22
+ /**
23
+ * Fixed-position screen header matching the Kaelo glass design system.
24
+ * Renders safe-area top inset + home icon + centered title/subtitle + optional right slot.
25
+ * Place as a sibling of (not inside) your ScrollView so it stays pinned while body scrolls.
26
+ */
27
+ export function ScreenHeader({
28
+ title,
29
+ subtitle,
30
+ leftIcon = 'home-outline',
31
+ leftAccessibilityLabel = 'Go to Dashboard',
32
+ onLeftPress,
33
+ right,
34
+ below,
35
+ style,
36
+ }: ScreenHeaderProps) {
37
+ const insets = useSafeAreaInsets();
38
+ const { colors } = useTheme();
39
+
40
+ return (
41
+ <View style={[styles.container, { paddingTop: insets.top + 16 }, style]}>
42
+ <View style={styles.row}>
43
+ <View style={styles.side}>
44
+ {onLeftPress && (
45
+ <TouchableOpacity
46
+ onPress={onLeftPress}
47
+ accessibilityLabel={leftAccessibilityLabel}
48
+ accessibilityRole="button"
49
+ hitSlop={8}
50
+ >
51
+ <Icon name={leftIcon as any} size={26} color={colors.icon.primary} />
52
+ </TouchableOpacity>
53
+ )}
54
+ </View>
55
+ <View style={styles.center}>
56
+ <Text
57
+ style={[styles.title, { color: colors.text.primary }]}
58
+ numberOfLines={1}
59
+ >
60
+ {title}
61
+ </Text>
62
+ {subtitle && (
63
+ <Text
64
+ style={[styles.subtitle, { color: colors.text.tertiary }]}
65
+ numberOfLines={1}
66
+ >
67
+ {subtitle}
68
+ </Text>
69
+ )}
70
+ </View>
71
+ <View style={styles.side}>{right}</View>
72
+ </View>
73
+ {below}
74
+ </View>
75
+ );
76
+ }
77
+
78
+ const styles = StyleSheet.create({
79
+ container: {
80
+ paddingHorizontal: 20,
81
+ paddingBottom: 8,
82
+ },
83
+ row: {
84
+ flexDirection: 'row',
85
+ alignItems: 'center',
86
+ marginBottom: 8,
87
+ },
88
+ side: {
89
+ width: 40,
90
+ alignItems: 'center',
91
+ justifyContent: 'center',
92
+ },
93
+ center: {
94
+ flex: 1,
95
+ alignItems: 'center',
96
+ },
97
+ title: {
98
+ fontSize: 22,
99
+ fontWeight: '700',
100
+ textAlign: 'center',
101
+ },
102
+ subtitle: {
103
+ fontSize: 13,
104
+ fontWeight: '500',
105
+ marginTop: 2,
106
+ },
107
+ });
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { Text, StyleSheet, StyleProp, TextStyle } from 'react-native';
3
+ import { useTheme } from '../hooks/useTheme';
4
+
5
+ export interface SectionHeaderProps {
6
+ children: React.ReactNode;
7
+ style?: StyleProp<TextStyle>;
8
+ }
9
+
10
+ /**
11
+ * Small uppercase section label used above lists of settings or cards.
12
+ * Matches the existing Profile / LogFood section headers.
13
+ */
14
+ export function SectionHeader({ children, style }: SectionHeaderProps) {
15
+ const { colors } = useTheme();
16
+ return (
17
+ <Text style={[styles.title, { color: colors.text.secondary }, style]}>
18
+ {children}
19
+ </Text>
20
+ );
21
+ }
22
+
23
+ const styles = StyleSheet.create({
24
+ title: {
25
+ fontSize: 13,
26
+ fontWeight: '700',
27
+ marginBottom: 12,
28
+ marginLeft: 4,
29
+ textTransform: 'uppercase',
30
+ letterSpacing: 1,
31
+ },
32
+ });
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ StyleProp,
8
+ ViewStyle,
9
+ } from 'react-native';
10
+ import { useTheme } from '../hooks/useTheme';
11
+
12
+ export interface SegmentedToggleOption<T extends string> {
13
+ value: T;
14
+ label: string;
15
+ accessibilityLabel?: string;
16
+ }
17
+
18
+ export interface SegmentedToggleProps<T extends string> {
19
+ options: SegmentedToggleOption<T>[];
20
+ value: T;
21
+ onChange: (value: T) => void;
22
+ /** `"pill"` = rounded pill row (tabs); `"chip"` = subtle inset toggle (filters). */
23
+ variant?: 'pill' | 'chip';
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+
27
+ /**
28
+ * Horizontal pill toggle used for Week/Month, Insights/History, etc.
29
+ * Replaces the ad-hoc tabBar/periodToggle rows in ProgressScreen and InsightsScreen.
30
+ */
31
+ export function SegmentedToggle<T extends string>({
32
+ options,
33
+ value,
34
+ onChange,
35
+ variant = 'pill',
36
+ style,
37
+ }: SegmentedToggleProps<T>) {
38
+ const { colors } = useTheme();
39
+ const isChip = variant === 'chip';
40
+
41
+ const containerBg = isChip ? colors.glass.backgroundLight : '#F0EFEA';
42
+ const activeBg = isChip ? colors.glass.backgroundHeavy : '#FFFFFF';
43
+
44
+ return (
45
+ <View
46
+ accessibilityRole="tablist"
47
+ style={[
48
+ styles.container,
49
+ { backgroundColor: containerBg },
50
+ isChip && styles.containerChip,
51
+ style,
52
+ ]}
53
+ >
54
+ {options.map((opt) => {
55
+ const active = opt.value === value;
56
+ return (
57
+ <TouchableOpacity
58
+ key={opt.value}
59
+ onPress={() => onChange(opt.value)}
60
+ accessibilityRole="tab"
61
+ accessibilityLabel={opt.accessibilityLabel ?? opt.label}
62
+ accessibilityState={{ selected: active }}
63
+ style={[
64
+ styles.segment,
65
+ active && { backgroundColor: activeBg },
66
+ active && styles.segmentActiveShadow,
67
+ ]}
68
+ >
69
+ <Text
70
+ style={[
71
+ styles.label,
72
+ {
73
+ color: active ? colors.text.primary : colors.text.tertiary,
74
+ },
75
+ ]}
76
+ >
77
+ {opt.label}
78
+ </Text>
79
+ </TouchableOpacity>
80
+ );
81
+ })}
82
+ </View>
83
+ );
84
+ }
85
+
86
+ const styles = StyleSheet.create({
87
+ container: {
88
+ flexDirection: 'row',
89
+ borderRadius: 12,
90
+ padding: 4,
91
+ },
92
+ containerChip: {
93
+ borderRadius: 10,
94
+ },
95
+ segment: {
96
+ flex: 1,
97
+ paddingVertical: 10,
98
+ alignItems: 'center',
99
+ borderRadius: 10,
100
+ },
101
+ segmentActiveShadow: {
102
+ shadowColor: '#000',
103
+ shadowOffset: { width: 0, height: 1 },
104
+ shadowOpacity: 0.06,
105
+ shadowRadius: 3,
106
+ elevation: 1,
107
+ },
108
+ label: {
109
+ fontSize: 14,
110
+ fontWeight: '600',
111
+ },
112
+ });
package/src/index.ts CHANGED
@@ -22,6 +22,20 @@ export { GlassHeaderBackground } from './components/GlassHeaderBackground';
22
22
  export { GlassInput } from './components/GlassInput';
23
23
  export { GlassListItem } from './components/GlassList';
24
24
  export { GlassStatsCard, GlassMacroCard, GlassNutrientRow } from './components/GlassStats';
25
+ export { RadioOption, RadioGroup } from './components/RadioOption';
26
+ export type { RadioOptionProps, RadioGroupProps } from './components/RadioOption';
27
+ export { ScreenHeader } from './components/ScreenHeader';
28
+ export type { ScreenHeaderProps } from './components/ScreenHeader';
29
+ export { SectionHeader } from './components/SectionHeader';
30
+ export type { SectionHeaderProps } from './components/SectionHeader';
31
+ export { SegmentedToggle } from './components/SegmentedToggle';
32
+ export type { SegmentedToggleProps, SegmentedToggleOption } from './components/SegmentedToggle';
33
+ export { EmptyState } from './components/EmptyState';
34
+ export type { EmptyStateProps } from './components/EmptyState';
35
+ export { ErrorState } from './components/ErrorState';
36
+ export type { ErrorStateProps } from './components/ErrorState';
37
+ export { LoadingState } from './components/LoadingState';
38
+ export type { LoadingStateProps } from './components/LoadingState';
25
39
 
26
40
  // Progress
27
41
  export { CircularProgress } from './components/progress/CircularProgress';