@klyftig/mobile-ui 1.3.2 → 1.3.4

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.3.2",
3
+ "version": "1.3.4",
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
  },
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';
2
+ import { TouchableOpacity, Text, View, StyleSheet, Switch } from 'react-native';
3
3
  import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
4
  import { useTheme } from '../hooks/useTheme';
5
5
 
@@ -12,6 +12,16 @@ interface GlassListItemProps {
12
12
  style?: any;
13
13
  }
14
14
 
15
+ interface GlassToggleItemProps {
16
+ title: string;
17
+ description?: string;
18
+ leftIcon?: string;
19
+ value: boolean;
20
+ onValueChange: (next: boolean) => void;
21
+ disabled?: boolean;
22
+ style?: any;
23
+ }
24
+
15
25
  export const GlassListItem: React.FC<GlassListItemProps> = ({
16
26
  title,
17
27
  description,
@@ -61,6 +71,52 @@ export const GlassListItem: React.FC<GlassListItemProps> = ({
61
71
  return content;
62
72
  };
63
73
 
74
+ // A list row that ends in a native Switch instead of a chevron/icon. The
75
+ // switch turns the theme's success green when on. Use this for settings
76
+ // toggles so the on/off state reads clearly (GlassListItem's rightIcon is a
77
+ // static tertiary-gray glyph and can't convey toggle state).
78
+ export const GlassToggleItem: React.FC<GlassToggleItemProps> = ({
79
+ title,
80
+ description,
81
+ leftIcon,
82
+ value,
83
+ onValueChange,
84
+ disabled,
85
+ style,
86
+ }) => {
87
+ const { colors } = useTheme();
88
+
89
+ return (
90
+ <View
91
+ style={[
92
+ styles.listItem,
93
+ { backgroundColor: colors.background.card, borderColor: colors.border.light },
94
+ style,
95
+ ]}
96
+ >
97
+ <View style={styles.listItemContent}>
98
+ {leftIcon && (
99
+ <Icon name={leftIcon} size={24} color={colors.icon.primary} style={styles.leftIcon} />
100
+ )}
101
+ <View style={styles.textContainer}>
102
+ <Text style={[styles.title, { color: colors.text.primary }]}>{title}</Text>
103
+ {description && <Text style={[styles.description, { color: colors.text.secondary }]}>{description}</Text>}
104
+ </View>
105
+ <Switch
106
+ value={value}
107
+ onValueChange={onValueChange}
108
+ disabled={disabled}
109
+ accessibilityLabel={title}
110
+ trackColor={{ false: '#E5E5EA', true: colors.accent.success }}
111
+ thumbColor="#FFFFFF"
112
+ ios_backgroundColor="#E5E5EA"
113
+ style={styles.toggleSwitch}
114
+ />
115
+ </View>
116
+ </View>
117
+ );
118
+ };
119
+
64
120
  const styles = StyleSheet.create({
65
121
  listItem: {
66
122
  borderRadius: 16,
@@ -94,4 +150,7 @@ const styles = StyleSheet.create({
94
150
  rightIcon: {
95
151
  marginLeft: 12,
96
152
  },
153
+ toggleSwitch: {
154
+ marginLeft: 12,
155
+ },
97
156
  });
@@ -1,12 +1,11 @@
1
- import React, { useCallback, useEffect, useMemo, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
- FlatList,
3
+ ScrollView,
4
4
  View,
5
5
  Text,
6
6
  StyleSheet,
7
7
  NativeSyntheticEvent,
8
8
  NativeScrollEvent,
9
- ListRenderItem,
10
9
  } from 'react-native';
11
10
  import { useTheme } from '../hooks/useTheme';
12
11
 
@@ -23,6 +22,11 @@ export interface WheelPickerProps {
23
22
 
24
23
  const DEFAULT_ITEM_HEIGHT = 48;
25
24
  const DEFAULT_VISIBLE_COUNT = 5;
25
+ // Render this many rows above and below the active index. The remaining rows
26
+ // are virtual — the scroll container still spans the full height so snapping
27
+ // and momentum behave normally, but we don't mount 600 <Text> nodes on first
28
+ // paint of MeasurementsStep.
29
+ const WINDOW_OVERSCAN = 8;
26
30
 
27
31
  export function WheelPicker({
28
32
  values,
@@ -35,7 +39,7 @@ export function WheelPicker({
35
39
  flex,
36
40
  }: WheelPickerProps) {
37
41
  const { colors } = useTheme();
38
- const listRef = useRef<FlatList<number>>(null);
42
+ const scrollRef = useRef<ScrollView>(null);
39
43
  const containerHeight = itemHeight * visibleCount;
40
44
  const padding = (containerHeight - itemHeight) / 2;
41
45
  const lastIndexRef = useRef<number>(-1);
@@ -45,20 +49,37 @@ export function WheelPicker({
45
49
  return idx >= 0 ? idx : 0;
46
50
  }, [values, selected]);
47
51
 
52
+ // Track the row currently centered under the selection overlay during scroll
53
+ // so the rendered window follows the user's drag, not just the committed
54
+ // `selected` prop (which only updates on momentum end).
55
+ const [windowCenter, setWindowCenter] = useState(selectedIndex);
56
+
48
57
  useEffect(() => {
49
58
  if (selectedIndex === lastIndexRef.current) return;
50
59
  const prev = lastIndexRef.current;
51
60
  lastIndexRef.current = selectedIndex;
52
- // Skip the very first effect — initialScrollIndex already positioned us.
61
+ setWindowCenter(selectedIndex);
62
+ // Skip on the very first effect — contentOffset already positioned us.
53
63
  if (prev === -1) return;
54
- listRef.current?.scrollToOffset({ offset: selectedIndex * itemHeight, animated: false });
64
+ scrollRef.current?.scrollTo({ y: selectedIndex * itemHeight, animated: false });
55
65
  }, [selectedIndex, itemHeight]);
56
66
 
67
+ const handleScroll = useCallback(
68
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
69
+ const offsetY = e.nativeEvent.contentOffset.y;
70
+ const index = Math.round(offsetY / itemHeight);
71
+ const clamped = Math.max(0, Math.min(values.length - 1, index));
72
+ setWindowCenter((prev) => (Math.abs(prev - clamped) >= 2 ? clamped : prev));
73
+ },
74
+ [itemHeight, values.length],
75
+ );
76
+
57
77
  const handleMomentumEnd = useCallback(
58
78
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
59
79
  const offsetY = e.nativeEvent.contentOffset.y;
60
80
  const index = Math.round(offsetY / itemHeight);
61
81
  const clamped = Math.max(0, Math.min(values.length - 1, index));
82
+ setWindowCenter(clamped);
62
83
  if (values[clamped] !== selected) {
63
84
  onChange(values[clamped]);
64
85
  }
@@ -66,38 +87,43 @@ export function WheelPicker({
66
87
  [itemHeight, values, selected, onChange],
67
88
  );
68
89
 
69
- const getItemLayout = useCallback(
70
- (_: ArrayLike<number> | null | undefined, index: number) => ({
71
- length: itemHeight,
72
- offset: itemHeight * index,
73
- index,
74
- }),
75
- [itemHeight],
76
- );
77
-
78
- const keyExtractor = useCallback((item: number) => String(item), []);
90
+ const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
91
+ const windowEnd = Math.min(values.length - 1, windowCenter + WINDOW_OVERSCAN);
79
92
 
80
93
  const dimColor = colors.text.tertiary;
81
94
  const activeColor = colors.text.primary;
82
95
 
83
- const renderItem: ListRenderItem<number> = useCallback(
84
- ({ item, index }) => {
85
- const isSelected = index === selectedIndex;
86
- return (
87
- <View style={[styles.item, { height: itemHeight }]}>
88
- <Text
89
- style={[
90
- styles.itemText,
91
- { color: isSelected ? activeColor : dimColor },
92
- ]}
93
- >
94
- {formatLabel ? formatLabel(item) : String(item)}
95
- </Text>
96
- </View>
97
- );
98
- },
99
- [selectedIndex, itemHeight, activeColor, dimColor, formatLabel],
100
- );
96
+ const items = [];
97
+ for (let i = windowStart; i <= windowEnd; i++) {
98
+ const value = values[i];
99
+ const isSelected = i === selectedIndex;
100
+ items.push(
101
+ <View
102
+ key={value}
103
+ style={[
104
+ styles.item,
105
+ {
106
+ position: 'absolute',
107
+ top: padding + i * itemHeight,
108
+ left: 0,
109
+ right: 0,
110
+ height: itemHeight,
111
+ },
112
+ ]}
113
+ >
114
+ <Text
115
+ style={[
116
+ styles.itemText,
117
+ { color: isSelected ? activeColor : dimColor },
118
+ ]}
119
+ >
120
+ {formatLabel ? formatLabel(value) : String(value)}
121
+ </Text>
122
+ </View>,
123
+ );
124
+ }
125
+
126
+ const totalContentHeight = values.length * itemHeight + padding * 2;
101
127
 
102
128
  return (
103
129
  <View style={[styles.wrapper, { height: containerHeight, width, flex }]}>
@@ -108,25 +134,19 @@ export function WheelPicker({
108
134
  { top: padding, height: itemHeight, backgroundColor: 'rgba(0,0,0,0.06)' },
109
135
  ]}
110
136
  />
111
- <FlatList
112
- ref={listRef}
113
- data={values}
114
- keyExtractor={keyExtractor}
115
- renderItem={renderItem}
116
- getItemLayout={getItemLayout}
117
- initialScrollIndex={selectedIndex}
118
- extraData={selectedIndex}
137
+ <ScrollView
138
+ ref={scrollRef}
119
139
  showsVerticalScrollIndicator={false}
120
140
  snapToInterval={itemHeight}
121
141
  decelerationRate="fast"
122
- contentContainerStyle={{ paddingTop: padding, paddingBottom: padding }}
142
+ onScroll={handleScroll}
143
+ scrollEventThrottle={32}
123
144
  onMomentumScrollEnd={handleMomentumEnd}
124
145
  nestedScrollEnabled
125
- initialNumToRender={visibleCount + 4}
126
- windowSize={3}
127
- maxToRenderPerBatch={visibleCount + 4}
128
- removeClippedSubviews
129
- />
146
+ contentOffset={{ x: 0, y: selectedIndex * itemHeight }}
147
+ >
148
+ <View style={{ height: totalContentHeight }}>{items}</View>
149
+ </ScrollView>
130
150
  </View>
131
151
  );
132
152
  }
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export { GlassCard } from './components/GlassCard';
20
20
  export { GlassContainer } from './components/GlassContainer';
21
21
  export { GlassHeaderBackground } from './components/GlassHeaderBackground';
22
22
  export { GlassInput } from './components/GlassInput';
23
- export { GlassListItem } from './components/GlassList';
23
+ export { GlassListItem, GlassToggleItem } from './components/GlassList';
24
24
  export { GlassStatsCard, GlassMacroCard, GlassNutrientRow } from './components/GlassStats';
25
25
  export { RadioOption, RadioGroup } from './components/RadioOption';
26
26
  export type { RadioOptionProps, RadioGroupProps } from './components/RadioOption';