@umituz/react-native-design-system 4.25.94 → 4.25.96

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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/atoms/AtomicFab.tsx +28 -32
  3. package/src/atoms/AtomicInput.tsx +11 -8
  4. package/src/atoms/AtomicProgress.tsx +7 -7
  5. package/src/atoms/AtomicSwitch.tsx +11 -7
  6. package/src/atoms/AtomicTouchable.tsx +10 -10
  7. package/src/atoms/chip/AtomicChip.tsx +13 -8
  8. package/src/atoms/image/AtomicImage.tsx +14 -10
  9. package/src/atoms/picker/components/PickerIcons.tsx +8 -2
  10. package/src/device/infrastructure/services/DeviceFeatureService.ts +4 -2
  11. package/src/device/infrastructure/services/PersistentDeviceIdService.ts +8 -2
  12. package/src/device/presentation/hooks/useDeviceFeatures.ts +8 -4
  13. package/src/image/infrastructure/services/ImageEnhanceService.ts +8 -5
  14. package/src/molecules/SearchBar/SearchBar.tsx +3 -3
  15. package/src/molecules/avatar/AvatarGroup.tsx +1 -1
  16. package/src/molecules/calendar/infrastructure/services/CalendarService.ts +2 -2
  17. package/src/molecules/filter-group/FilterGroup.tsx +3 -4
  18. package/src/molecules/icon-grid/IconGrid.tsx +149 -0
  19. package/src/molecules/icon-grid/index.ts +1 -0
  20. package/src/molecules/index.ts +2 -0
  21. package/src/onboarding/presentation/hooks/useOnboardingGestures.ts +3 -10
  22. package/src/storage/presentation/hooks/usePersistentCache.ts +1 -3
  23. package/src/storage/presentation/hooks/useStorageState.ts +27 -15
  24. package/src/tanstack/infrastructure/monitoring/DevMonitor.ts +2 -0
  25. package/src/theme/infrastructure/stores/themeStore.ts +12 -3
  26. package/src/timezone/infrastructure/utils/SimpleCache.ts +5 -0
  27. package/src/utils/hooks/useAsyncOperation.ts +4 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-design-system",
3
- "version": "4.25.94",
3
+ "version": "4.25.96",
4
4
  "description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive, safe area, exception, infinite scroll, UUID, image, timezone, offline, onboarding, and loading utilities",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { TouchableOpacity, StyleSheet } from 'react-native';
3
3
  import { useAppDesignTokens } from '../theme';
4
4
  import { useResponsive } from '../responsive';
@@ -57,45 +57,41 @@ export const AtomicFab: React.FC<AtomicFabProps> = ({
57
57
  }) => {
58
58
  const tokens = useAppDesignTokens();
59
59
  const responsive = useResponsive();
60
- const isDisabled = disabled;
61
60
 
62
- // Get configurations
63
- const baseSizeConfig = FAB_SIZES[size as 'sm' | 'md' | 'lg'];
64
- const variants = getFabVariants(tokens);
65
- const variantConfig = variants[variant as 'primary' | 'secondary' | 'surface'];
66
- const baseIconSize = getFabIconSize(size as 'sm' | 'md' | 'lg');
61
+ const variantConfig = useMemo(() => getFabVariants(tokens)[variant as 'primary' | 'secondary' | 'surface'], [tokens, variant]);
62
+ const iconSize = useMemo(() => getFabIconSize(size as 'sm' | 'md' | 'lg') * tokens.spacingMultiplier, [size, tokens.spacingMultiplier]);
67
63
 
68
- // Scale dimensions
69
- const sizeConfig = {
70
- width: baseSizeConfig.width * tokens.spacingMultiplier,
71
- height: baseSizeConfig.height * tokens.spacingMultiplier,
72
- borderRadius: baseSizeConfig.borderRadius * tokens.spacingMultiplier,
73
- };
74
- const iconSize = baseIconSize * tokens.spacingMultiplier;
64
+ const fabStyle = useMemo(() => {
65
+ const baseSizeConfig = FAB_SIZES[size as 'sm' | 'md' | 'lg'];
66
+ const sizeConfig = {
67
+ width: baseSizeConfig.width * tokens.spacingMultiplier,
68
+ height: baseSizeConfig.height * tokens.spacingMultiplier,
69
+ borderRadius: baseSizeConfig.borderRadius * tokens.spacingMultiplier,
70
+ };
75
71
 
76
- // Combine styles
77
- const fabStyle = StyleSheet.flatten([
78
- {
79
- position: 'absolute' as const,
80
- bottom: responsive.fabPosition.bottom,
81
- right: responsive.fabPosition.right,
82
- width: sizeConfig.width,
83
- height: sizeConfig.height,
84
- borderRadius: sizeConfig.borderRadius,
85
- backgroundColor: variantConfig.backgroundColor,
86
- alignItems: 'center' as const,
87
- justifyContent: 'center' as const,
88
- },
89
- getFabBorder(tokens),
90
- isDisabled ? { opacity: tokens.opacity.disabled } : undefined,
91
- style, // Custom style override
92
- ]);
72
+ return StyleSheet.flatten([
73
+ {
74
+ position: 'absolute' as const,
75
+ bottom: responsive.fabPosition.bottom,
76
+ right: responsive.fabPosition.right,
77
+ width: sizeConfig.width,
78
+ height: sizeConfig.height,
79
+ borderRadius: sizeConfig.borderRadius,
80
+ backgroundColor: variantConfig.backgroundColor,
81
+ alignItems: 'center' as const,
82
+ justifyContent: 'center' as const,
83
+ },
84
+ getFabBorder(tokens),
85
+ disabled ? { opacity: tokens.opacity.disabled } : undefined,
86
+ style,
87
+ ]);
88
+ }, [size, tokens, responsive.fabPosition, variantConfig, disabled, style]);
93
89
 
94
90
  return (
95
91
  <TouchableOpacity
96
92
  style={fabStyle}
97
93
  onPress={onPress}
98
- disabled={isDisabled}
94
+ disabled={disabled}
99
95
  activeOpacity={activeOpacity}
100
96
  testID={testID}
101
97
  accessibilityLabel={accessibilityLabel || `${icon} floating action button`}
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useCallback, useMemo } from 'react';
2
2
  import { View, TextInput, StyleProp, ViewStyle, TextStyle } from 'react-native';
3
3
  import { useAppDesignTokens } from '../theme';
4
4
  import { useInputState } from './input/hooks/useInputState';
@@ -69,7 +69,7 @@ export const AtomicInput = React.forwardRef<React.ElementRef<typeof TextInput>,
69
69
  const iconColor = isDisabled ? tokens.colors.textDisabled : tokens.colors.textSecondary;
70
70
  const iconPadding = sizeConfig.iconSize + 8;
71
71
 
72
- const containerStyle: StyleProp<ViewStyle> = [
72
+ const containerStyle = useMemo<StyleProp<ViewStyle>>(() => [
73
73
  inputStyles.container,
74
74
  variantStyle,
75
75
  {
@@ -77,13 +77,16 @@ export const AtomicInput = React.forwardRef<React.ElementRef<typeof TextInput>,
77
77
  paddingBottom: sizeConfig.paddingVertical,
78
78
  paddingHorizontal: sizeConfig.paddingHorizontal,
79
79
  minHeight: sizeConfig.minHeight,
80
- justifyContent: 'center',
80
+ justifyContent: 'center' as const,
81
81
  opacity: isDisabled ? 0.5 : 1,
82
82
  },
83
83
  style,
84
- ];
84
+ ], [variantStyle, sizeConfig, isDisabled, style]);
85
85
 
86
- const textInputStyle: StyleProp<TextStyle> = [
86
+ const handleBlur = useCallback(() => { setIsFocused(false); onBlur?.(); }, [setIsFocused, onBlur]);
87
+ const handleFocus = useCallback(() => { setIsFocused(true); onFocus?.(); }, [setIsFocused, onFocus]);
88
+
89
+ const textInputStyle = useMemo<StyleProp<TextStyle>>(() => [
87
90
  inputStyles.input,
88
91
  {
89
92
  fontSize: sizeConfig.fontSize,
@@ -94,7 +97,7 @@ export const AtomicInput = React.forwardRef<React.ElementRef<typeof TextInput>,
94
97
  paddingRight: (trailingIcon || showPasswordToggle) ? iconPadding : undefined,
95
98
  },
96
99
  inputStyle,
97
- ];
100
+ ], [sizeConfig, textColor, leadingIcon, trailingIcon, showPasswordToggle, iconPadding, inputStyle]);
98
101
 
99
102
  return (
100
103
  <View testID={testID}>
@@ -123,8 +126,8 @@ export const AtomicInput = React.forwardRef<React.ElementRef<typeof TextInput>,
123
126
  numberOfLines={numberOfLines}
124
127
  textContentType={textContentType}
125
128
  style={textInputStyle}
126
- onBlur={() => { setIsFocused(false); onBlur?.(); }}
127
- onFocus={() => { setIsFocused(true); onFocus?.(); }}
129
+ onBlur={handleBlur}
130
+ onFocus={handleFocus}
128
131
  testID={testID ? `${testID}-input` : undefined}
129
132
  />
130
133
 
@@ -14,7 +14,7 @@
14
14
  * - Form completion
15
15
  */
16
16
 
17
- import React from 'react';
17
+ import React, { useMemo } from 'react';
18
18
  import { View, StyleSheet, ViewStyle, DimensionValue, Text } from 'react-native';
19
19
  import { useAppDesignTokens } from '../theme';
20
20
 
@@ -82,27 +82,27 @@ export const AtomicProgress: React.FC<AtomicProgressProps> = ({
82
82
  // Border radius based on shape
83
83
  const progressBorderRadius = shape === 'rounded' ? scaledHeight / 2 : 0;
84
84
 
85
- const containerStyle: ViewStyle = {
85
+ const containerStyle = useMemo<ViewStyle>(() => ({
86
86
  width: width as DimensionValue,
87
87
  height: scaledHeight,
88
88
  backgroundColor: progressBackground,
89
89
  borderRadius: progressBorderRadius,
90
90
  overflow: 'hidden',
91
- };
91
+ }), [width, scaledHeight, progressBackground, progressBorderRadius]);
92
92
 
93
- const progressStyle: ViewStyle = {
93
+ const progressStyle = useMemo<ViewStyle>(() => ({
94
94
  width: progressWidth as DimensionValue,
95
95
  height: '100%' as DimensionValue,
96
96
  backgroundColor: progressColor,
97
97
  borderRadius: progressBorderRadius,
98
- };
98
+ }), [progressWidth, progressColor, progressBorderRadius]);
99
99
 
100
- const textStyle = {
100
+ const textStyle = useMemo(() => ({
101
101
  fontSize: tokens.typography.bodySmall.responsiveFontSize,
102
102
  fontWeight: tokens.typography.labelMedium.fontWeight,
103
103
  color: progressTextColor,
104
104
  textAlign: 'center' as const,
105
- };
105
+ }), [tokens.typography.bodySmall.responsiveFontSize, tokens.typography.labelMedium.fontWeight, progressTextColor]);
106
106
 
107
107
  return (
108
108
  <View
@@ -5,7 +5,7 @@
5
5
  * Purpose: Boolean toggle across all apps
6
6
  */
7
7
 
8
- import React from 'react';
8
+ import React, { useMemo } from 'react';
9
9
  import { View, Switch, StyleSheet, ViewStyle } from 'react-native';
10
10
  import { useAppDesignTokens } from '../theme';
11
11
  import { AtomicText } from './AtomicText';
@@ -31,6 +31,10 @@ export const AtomicSwitch: React.FC<AtomicSwitchProps> = ({
31
31
  }) => {
32
32
  const tokens = useAppDesignTokens();
33
33
 
34
+ const labelStyle = useMemo(() => ({ color: tokens.colors.textPrimary }), [tokens.colors.textPrimary]);
35
+ const descriptionStyle = useMemo(() => ({ color: tokens.colors.textSecondary, marginTop: 2 }), [tokens.colors.textSecondary]);
36
+ const trackColor = useMemo(() => ({ false: tokens.colors.border, true: tokens.colors.primary }), [tokens.colors.border, tokens.colors.primary]);
37
+
34
38
  return (
35
39
  <View style={[styles.container, style]} testID={testID}>
36
40
  <View style={styles.row}>
@@ -38,14 +42,14 @@ export const AtomicSwitch: React.FC<AtomicSwitchProps> = ({
38
42
  <View style={styles.labelContainer}>
39
43
  <AtomicText
40
44
  type="bodyMedium"
41
- style={{ color: tokens.colors.textPrimary }}
45
+ style={labelStyle}
42
46
  >
43
47
  {label}
44
48
  </AtomicText>
45
49
  {description && (
46
50
  <AtomicText
47
51
  type="bodySmall"
48
- style={{ color: tokens.colors.textSecondary, marginTop: 2 }}
52
+ style={descriptionStyle}
49
53
  >
50
54
  {description}
51
55
  </AtomicText>
@@ -56,11 +60,11 @@ export const AtomicSwitch: React.FC<AtomicSwitchProps> = ({
56
60
  value={value}
57
61
  onValueChange={onValueChange}
58
62
  disabled={disabled}
59
- trackColor={{
60
- false: tokens.colors.border,
61
- true: tokens.colors.primary,
62
- }}
63
+ trackColor={trackColor}
63
64
  thumbColor={value ? tokens.colors.onPrimary : tokens.colors.surface}
65
+ accessibilityRole="switch"
66
+ accessibilityLabel={label}
67
+ accessibilityState={{ checked: value, disabled }}
64
68
  />
65
69
  </View>
66
70
  </View>
@@ -5,7 +5,7 @@
5
5
  * Purpose: Touchable wrapper across all apps
6
6
  */
7
7
 
8
- import React, { useRef } from 'react';
8
+ import React, { useRef, useCallback } from 'react';
9
9
  import { TouchableOpacity, ViewStyle, StyleProp } from 'react-native';
10
10
 
11
11
  const DEBOUNCE_MS = 300;
@@ -47,19 +47,19 @@ const AtomicTouchableComponent: React.FC<AtomicTouchableProps> = ({
47
47
  accessible = true,
48
48
  }) => {
49
49
  const lastPressRef = useRef(0);
50
+ const onPressRef = useRef(onPress);
51
+ onPressRef.current = onPress;
50
52
 
51
- const handlePress = onPress
52
- ? () => {
53
- const now = Date.now();
54
- if (now - lastPressRef.current < DEBOUNCE_MS) return;
55
- lastPressRef.current = now;
56
- onPress();
57
- }
58
- : undefined;
53
+ const handlePress = useCallback(() => {
54
+ const now = Date.now();
55
+ if (now - lastPressRef.current < DEBOUNCE_MS) return;
56
+ lastPressRef.current = now;
57
+ onPressRef.current?.();
58
+ }, []);
59
59
 
60
60
  return (
61
61
  <TouchableOpacity
62
- onPress={handlePress}
62
+ onPress={onPress ? handlePress : undefined}
63
63
  onLongPress={onLongPress}
64
64
  disabled={disabled}
65
65
  activeOpacity={activeOpacity}
@@ -47,7 +47,7 @@ export const AtomicChip: React.FC<AtomicChipProps> = React.memo(({
47
47
  const isDisabled = disabled || (!clickable && !onPress);
48
48
  const opacity = isDisabled ? 0.5 : 1;
49
49
 
50
- const chipStyle: ViewStyle = {
50
+ const chipStyle = useMemo<ViewStyle>(() => ({
51
51
  flexDirection: 'row',
52
52
  alignItems: 'center',
53
53
  justifyContent: 'center',
@@ -58,23 +58,28 @@ export const AtomicChip: React.FC<AtomicChipProps> = React.memo(({
58
58
  ...borderStyle,
59
59
  borderColor: finalBorderColor,
60
60
  ...selectedStyle,
61
- };
61
+ }), [sizeConfig, finalBackgroundColor, opacity, borderStyle, finalBorderColor, selectedStyle]);
62
62
 
63
- const textStyle = {
63
+ const textStyle = useMemo(() => ({
64
64
  fontSize: sizeConfig.fontSize,
65
65
  fontWeight: tokens.typography.medium,
66
- };
66
+ }), [sizeConfig.fontSize, tokens.typography.medium]);
67
67
 
68
68
  const iconColor = finalTextColor;
69
69
 
70
- const Component = (clickable && onPress && !disabled) ? TouchableOpacity : View;
71
- const componentProps = (clickable && onPress && !disabled) ? { onPress, activeOpacity } : {};
70
+ const isInteractive = clickable && onPress && !disabled;
71
+ const Component = isInteractive ? TouchableOpacity : View;
72
+ const componentProps = isInteractive ? { onPress, activeOpacity } : {};
73
+ const accessibilityProps = isInteractive
74
+ ? { accessibilityRole: 'button' as const, accessibilityState: { selected, disabled } }
75
+ : {};
72
76
 
73
77
  return (
74
- <Component
75
- style={[chipStyle, style]}
78
+ <Component
79
+ style={[chipStyle, style]}
76
80
  testID={testID}
77
81
  {...componentProps}
82
+ {...accessibilityProps}
78
83
  >
79
84
  {leadingIcon && (
80
85
  <AtomicIcon
@@ -19,6 +19,16 @@ export type AtomicImageProps = {
19
19
  [key: string]: any;
20
20
  };
21
21
 
22
+ const RESIZE_MODE_MAP: Record<string, 'cover' | 'contain' | 'stretch' | 'center'> = {
23
+ cover: 'cover',
24
+ contain: 'contain',
25
+ fill: 'stretch',
26
+ none: 'center',
27
+ 'scale-down': 'contain',
28
+ };
29
+
30
+ const ROUNDED_STYLE = { borderRadius: 9999 };
31
+
22
32
  export const AtomicImage: React.FC<AtomicImageProps> = ({
23
33
  style,
24
34
  rounded,
@@ -26,7 +36,7 @@ export const AtomicImage: React.FC<AtomicImageProps> = ({
26
36
  cachePolicy,
27
37
  ...props
28
38
  }) => {
29
- const roundedStyle = rounded ? { borderRadius: 9999 } : undefined;
39
+ const roundedStyle = rounded ? ROUNDED_STYLE : undefined;
30
40
 
31
41
  if (ExpoImage) {
32
42
  return (
@@ -34,23 +44,17 @@ export const AtomicImage: React.FC<AtomicImageProps> = ({
34
44
  style={[style, roundedStyle]}
35
45
  contentFit={contentFit}
36
46
  cachePolicy={cachePolicy}
47
+ accessibilityRole="image"
37
48
  {...props}
38
49
  />
39
50
  );
40
51
  }
41
52
 
42
- // Fallback: React Native Image
43
- const resizeModeMap: Record<string, 'cover' | 'contain' | 'stretch' | 'center'> = {
44
- cover: 'cover',
45
- contain: 'contain',
46
- fill: 'stretch',
47
- none: 'center',
48
- 'scale-down': 'contain',
49
- };
50
53
  return (
51
54
  <RNImage
52
55
  style={[style as StyleProp<ImageStyle>, roundedStyle]}
53
- resizeMode={resizeModeMap[contentFit] ?? 'cover'}
56
+ resizeMode={RESIZE_MODE_MAP[contentFit] ?? 'cover'}
57
+ accessibilityRole="image"
54
58
  {...props}
55
59
  />
56
60
  );
@@ -3,7 +3,7 @@
3
3
  * Renders clear button and dropdown icon for AtomicPicker
4
4
  */
5
5
 
6
- import React from 'react';
6
+ import React, { useMemo } from 'react';
7
7
  import { TouchableOpacity, View } from 'react-native';
8
8
  import { useAppDesignTokens } from '../../../theme';
9
9
  import { AtomicIcon, useIconName } from '../../icon';
@@ -33,8 +33,14 @@ export const PickerIcons: React.FC<PickerIconsProps> = ({
33
33
  const chevronUpIcon = useIconName('chevronUp');
34
34
  const chevronDownIcon = useIconName('chevronDown');
35
35
 
36
+ const containerStyle = useMemo(() => ({
37
+ flexDirection: 'row' as const,
38
+ alignItems: 'center' as const,
39
+ gap: tokens.spacing.xs,
40
+ }), [tokens.spacing.xs]);
41
+
36
42
  return (
37
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: tokens.spacing.xs }}>
43
+ <View style={containerStyle}>
38
44
  {/* Clear Button */}
39
45
  {clearable && selectedOptionsCount > 0 && !disabled && (
40
46
  <TouchableOpacity
@@ -112,8 +112,10 @@ export class DeviceFeatureService {
112
112
  const key = this.getStorageKey(deviceId, featureName);
113
113
  try {
114
114
  await storageRepository.setItem(key, usage);
115
- } catch {
116
- // Silent fail
115
+ } catch (error) {
116
+ if (__DEV__) {
117
+ console.warn(`[DesignSystem] DeviceFeatureService: Failed to persist usage for "${featureName}"`, error);
118
+ }
117
119
  }
118
120
  }
119
121
 
@@ -99,14 +99,20 @@ export class PersistentDeviceIdService {
99
99
  */
100
100
  static async clearStoredId(): Promise<void> {
101
101
  try {
102
+ // Wait for pending initialization to prevent race condition
103
+ if (initializationPromise) {
104
+ await initializationPromise.catch(() => {});
105
+ }
102
106
  await this.secureRepo.remove();
103
107
  cachedDeviceId = null;
104
108
  initializationPromise = null;
105
109
  if (__DEV__) {
106
110
  console.log('[DesignSystem] Device ID: Stored ID cleared');
107
111
  }
108
- } catch {
109
- // Silent fail
112
+ } catch (error) {
113
+ if (__DEV__) {
114
+ console.warn('[DesignSystem] Device ID: Failed to clear stored ID', error);
115
+ }
110
116
  }
111
117
  }
112
118
 
@@ -32,8 +32,10 @@ export function useDeviceFeatures(
32
32
  try {
33
33
  const result = await DeviceFeatureService.checkFeatureAccess(featureName);
34
34
  setAccess(result);
35
- } catch {
36
- // Silent fail
35
+ } catch (error) {
36
+ if (__DEV__) {
37
+ console.warn(`[DesignSystem] useDeviceFeatures: Failed to check access for "${featureName}"`, error);
38
+ }
37
39
  }
38
40
  }, [featureName]);
39
41
 
@@ -41,8 +43,10 @@ export function useDeviceFeatures(
41
43
  try {
42
44
  await DeviceFeatureService.incrementFeatureUsage(featureName);
43
45
  await checkAccess();
44
- } catch {
45
- // Silent fail
46
+ } catch (error) {
47
+ if (__DEV__) {
48
+ console.warn(`[DesignSystem] useDeviceFeatures: Failed to increment usage for "${featureName}"`, error);
49
+ }
46
50
  }
47
51
  }, [featureName, checkAccess]);
48
52
 
@@ -36,12 +36,15 @@ export class ImageEnhanceService {
36
36
  throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'analyzeImage');
37
37
  }
38
38
 
39
+ if (__DEV__) {
40
+ console.warn('[DesignSystem] ImageEnhanceService.analyzeImage: Returning placeholder metrics. Real image analysis is not yet implemented.');
41
+ }
39
42
  return {
40
- sharpness: Math.random() * 100,
41
- brightness: Math.random() * 100,
42
- contrast: Math.random() * 100,
43
- colorfulness: Math.random() * 100,
44
- overallQuality: Math.random() * 100,
43
+ sharpness: 50,
44
+ brightness: 50,
45
+ contrast: 50,
46
+ colorfulness: 50,
47
+ overallQuality: 50,
45
48
  };
46
49
  } catch (error) {
47
50
  throw ImageErrorHandler.handleUnknownError(error, 'analyzeImage');
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import {
3
3
  View,
4
4
  TextInput,
@@ -28,10 +28,10 @@ export const SearchBar: React.FC<SearchBarProps> = ({
28
28
  const searchIcon = useIconName('search');
29
29
  const closeCircleIcon = useIconName('closeCircle');
30
30
 
31
- const handleClear = () => {
31
+ const handleClear = useCallback(() => {
32
32
  onChangeText('');
33
33
  onClear?.();
34
- };
34
+ }, [onChangeText, onClear]);
35
35
 
36
36
  const showClear = value.length > 0 && !loading;
37
37
 
@@ -60,7 +60,7 @@ export const AvatarGroup: React.FC<AvatarGroupProps> = ({
60
60
  <View style={[styles.container, style]}>
61
61
  {visibleItems.map((item, index) => (
62
62
  <View
63
- key={item.uri || item.name || item.icon}
63
+ key={item.uri || item.name || item.icon || `avatar-${index}`}
64
64
  style={[
65
65
  styles.avatarWrapper,
66
66
  index > 0 && { marginLeft: spacing },
@@ -96,12 +96,12 @@ export class CalendarService {
96
96
  /**
97
97
  * Get weekday names
98
98
  */
99
- static getWeekdayNames(): string[] {
99
+ static getWeekdayNames(locale?: string): string[] {
100
100
  const weekdays: string[] = [];
101
101
  for (let i = 0; i < 7; i++) {
102
102
  const date = new Date();
103
103
  date.setDate(date.getDate() - date.getDay() + i);
104
- weekdays.push(date.toLocaleDateString('en-US', { weekday: 'short' }));
104
+ weekdays.push(date.toLocaleDateString(locale ?? undefined, { weekday: 'short' }));
105
105
  }
106
106
  return weekdays;
107
107
  }
@@ -1,5 +1,5 @@
1
1
 
2
- import React from 'react';
2
+ import React, { useMemo } from 'react';
3
3
  import { ScrollView, StyleSheet } from 'react-native';
4
4
  import { AtomicChip } from '../../atoms/chip/AtomicChip';
5
5
  import { useAppDesignTokens } from '../../theme';
@@ -16,7 +16,7 @@ export function FilterGroup<T = string>({
16
16
  }: FilterGroupProps<T>) {
17
17
  const tokens = useAppDesignTokens();
18
18
 
19
- const styles = StyleSheet.create({
19
+ const styles = useMemo(() => StyleSheet.create({
20
20
  container: {
21
21
  flexGrow: 0,
22
22
  },
@@ -26,9 +26,8 @@ export function FilterGroup<T = string>({
26
26
  alignItems: 'center',
27
27
  },
28
28
  item: {
29
- // Default styles if needed, though AtomicChip handles most
30
29
  },
31
- });
30
+ }), [tokens.spacing.md]);
32
31
 
33
32
  return (
34
33
  <ScrollView
@@ -0,0 +1,149 @@
1
+ /**
2
+ * IconGrid - Reusable icon card grid
3
+ *
4
+ * Self-measuring grid that calculates item widths from actual container width.
5
+ * Works correctly inside ScreenLayout (or any padded container) without needing
6
+ * to know the parent's padding upfront.
7
+ */
8
+
9
+ import React, { useCallback, useMemo, useState } from 'react';
10
+ import {
11
+ View,
12
+ TouchableOpacity,
13
+ StyleSheet,
14
+ type LayoutChangeEvent,
15
+ type StyleProp,
16
+ type ViewStyle,
17
+ } from 'react-native';
18
+ import { useAppDesignTokens } from '../../theme';
19
+ import { AtomicIcon } from '../../atoms';
20
+ import { AtomicText } from '../../atoms';
21
+ import type { IconName } from '../../atoms';
22
+
23
+ export interface IconGridItem {
24
+ /** Unique identifier */
25
+ id: string;
26
+ /** Label shown below the icon */
27
+ label: string;
28
+ /** Icon name (AtomicIcon / Lucide-based) */
29
+ icon: IconName;
30
+ /** Called when the card is tapped */
31
+ onPress: () => void;
32
+ }
33
+
34
+ export interface IconGridProps {
35
+ /** Items to display */
36
+ items: IconGridItem[];
37
+ /** Number of columns (default: 3) */
38
+ columns?: number;
39
+ /** Gap between items in pixels (default: 10) */
40
+ gap?: number;
41
+ /** Vertical gap between rows (default: same as gap) */
42
+ rowGap?: number;
43
+ /** Optional container style */
44
+ style?: StyleProp<ViewStyle>;
45
+ }
46
+
47
+ /**
48
+ * A self-sizing icon card grid.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * <IconGrid
53
+ * columns={3}
54
+ * items={[
55
+ * { id: 'aging', label: 'Aging', icon: 'flash', onPress: () => navigate('Aging') },
56
+ * { id: 'wardrobe', label: 'Wardrobe', icon: 'shirt', onPress: () => navigate('Wardrobe') },
57
+ * ]}
58
+ * />
59
+ * ```
60
+ */
61
+ export const IconGrid: React.FC<IconGridProps> = ({
62
+ items,
63
+ columns = 3,
64
+ gap = 10,
65
+ rowGap,
66
+ style,
67
+ }) => {
68
+ const tokens = useAppDesignTokens();
69
+ const [containerWidth, setContainerWidth] = useState(0);
70
+
71
+ const handleLayout = useCallback(
72
+ (e: LayoutChangeEvent) => {
73
+ const { width } = e.nativeEvent.layout;
74
+ if (width > 0 && width !== containerWidth) {
75
+ setContainerWidth(width);
76
+ }
77
+ },
78
+ [containerWidth],
79
+ );
80
+
81
+ // Total gap space between columns (N columns = N-1 gaps)
82
+ const itemWidth = useMemo(() => {
83
+ if (containerWidth <= 0) return 0;
84
+ const totalGap = gap * (columns - 1);
85
+ // Subtract 1px safety margin to prevent sub-pixel wrapping
86
+ return Math.floor((containerWidth - totalGap) / columns) - 1;
87
+ }, [containerWidth, columns, gap]);
88
+
89
+ const { cardBackground, borderLight, textPrimary } = tokens.colors;
90
+
91
+ return (
92
+ <View
93
+ style={[styles.grid, { columnGap: gap, rowGap: rowGap ?? gap }, style]}
94
+ onLayout={handleLayout}
95
+ >
96
+ {items.map((item) =>
97
+ itemWidth > 0 ? (
98
+ <TouchableOpacity
99
+ key={item.id}
100
+ activeOpacity={0.7}
101
+ onPress={item.onPress}
102
+ style={[styles.card, { width: itemWidth }]}
103
+ >
104
+ <View
105
+ style={[
106
+ styles.iconBox,
107
+ { width: itemWidth, backgroundColor: cardBackground, borderColor: borderLight },
108
+ ]}
109
+ >
110
+ <AtomicIcon name={item.icon} size="lg" color="textPrimary" />
111
+ </View>
112
+ <AtomicText
113
+ style={[styles.label, { color: textPrimary }]}
114
+ numberOfLines={1}
115
+ >
116
+ {item.label}
117
+ </AtomicText>
118
+ </TouchableOpacity>
119
+ ) : (
120
+ // Placeholder — keeps grid stable before first layout measurement
121
+ <View key={item.id} style={{ width: 0, height: 0 }} />
122
+ ),
123
+ )}
124
+ </View>
125
+ );
126
+ };
127
+
128
+ const styles = StyleSheet.create({
129
+ grid: {
130
+ flexDirection: 'row',
131
+ flexWrap: 'wrap',
132
+ },
133
+ card: {
134
+ alignItems: 'center',
135
+ gap: 8,
136
+ },
137
+ iconBox: {
138
+ aspectRatio: 1,
139
+ borderRadius: 24,
140
+ alignItems: 'center',
141
+ justifyContent: 'center',
142
+ borderWidth: 1,
143
+ },
144
+ label: {
145
+ fontSize: 11,
146
+ fontWeight: '700',
147
+ textAlign: 'center',
148
+ },
149
+ });
@@ -0,0 +1 @@
1
+ export { IconGrid, type IconGridItem, type IconGridProps } from './IconGrid';
@@ -65,3 +65,5 @@ export * from './info-grid';
65
65
 
66
66
 
67
67
  export * from './circular-menu';
68
+
69
+ export * from './icon-grid';
@@ -3,7 +3,7 @@
3
3
  * Handles swipe gestures for onboarding navigation
4
4
  */
5
5
 
6
- import { useRef, useEffect } from "react";
6
+ import { useRef } from "react";
7
7
  import { PanResponder } from "react-native";
8
8
 
9
9
  interface UseOnboardingGesturesProps {
@@ -27,15 +27,8 @@ export const useOnboardingGestures = ({
27
27
  onBack,
28
28
  });
29
29
 
30
- // Update refs on every render to ensure PanResponder has fresh values
31
- useEffect(() => {
32
- latestPropsRef.current = {
33
- isFirstSlide,
34
- isAnswerValid,
35
- onNext,
36
- onBack,
37
- };
38
- });
30
+ // Update ref on every render to ensure PanResponder has fresh values
31
+ latestPropsRef.current = { isFirstSlide, isAnswerValid, onNext, onBack };
39
32
 
40
33
  const panResponder = useRef(
41
34
  PanResponder.create({
@@ -101,9 +101,7 @@ export function usePersistentCache<T>(
101
101
 
102
102
  // Stabilize actions to prevent circular dependency
103
103
  const stableActionsRef = useRef(actions);
104
- useEffect(() => {
105
- stableActionsRef.current = actions;
106
- });
104
+ stableActionsRef.current = actions;
107
105
 
108
106
  const loadFromStorage = useCallback(async () => {
109
107
  const currentActions = stableActionsRef.current;
@@ -5,11 +5,10 @@
5
5
  * Combines React state with automatic storage persistence
6
6
  */
7
7
 
8
- import { useState, useCallback } from 'react';
8
+ import { useState, useCallback, useEffect, useRef } from 'react';
9
9
  import { storageRepository } from '../../infrastructure/repositories/AsyncStorageRepository';
10
10
  import { unwrap } from '../../domain/entities/StorageResult';
11
11
  import type { StorageKey } from '../../domain/value-objects/StorageKey';
12
- import { useAsyncOperation } from '../../../utils/hooks';
13
12
 
14
13
  /**
15
14
  * Storage State Hook
@@ -27,20 +26,33 @@ export const useStorageState = <T>(
27
26
  ): [T, (value: T) => Promise<void>, boolean] => {
28
27
  const keyString = typeof key === 'string' ? key : String(key);
29
28
  const [state, setState] = useState<T>(defaultValue);
29
+ const [isLoading, setIsLoading] = useState(true);
30
+ const isMountedRef = useRef(true);
30
31
 
31
- // Load initial value from storage
32
- const { data, isLoading } = useAsyncOperation<T, Error>(
33
- async () => {
34
- const result = await storageRepository.getItem(keyString, defaultValue);
35
- return unwrap(result, defaultValue);
36
- },
37
- {
38
- immediate: true,
39
- initialData: defaultValue,
40
- errorHandler: (err) => err as Error,
41
- onSuccess: (value) => setState(value),
42
- }
43
- );
32
+ useEffect(() => {
33
+ isMountedRef.current = true;
34
+ setIsLoading(true);
35
+
36
+ storageRepository
37
+ .getItem<T>(keyString, defaultValue)
38
+ .then((result) => {
39
+ if (isMountedRef.current) {
40
+ setState(unwrap(result, defaultValue));
41
+ }
42
+ })
43
+ .catch(() => {
44
+ // Keep defaultValue on error
45
+ })
46
+ .finally(() => {
47
+ if (isMountedRef.current) {
48
+ setIsLoading(false);
49
+ }
50
+ });
51
+
52
+ return () => {
53
+ isMountedRef.current = false;
54
+ };
55
+ }, [keyString]);
44
56
 
45
57
  // Update state and persist to storage
46
58
  const updateState = useCallback(
@@ -95,6 +95,8 @@ class DevMonitorClass {
95
95
  this.cacheSubscription = null;
96
96
  }
97
97
 
98
+ this.stopStatsLogging();
99
+ this.metrics.clear();
98
100
  this.queryClient = null;
99
101
  }
100
102
 
@@ -103,9 +103,18 @@ export const useTheme = createStore<ThemeState, ThemeActions>({
103
103
  },
104
104
 
105
105
  setCustomColors: async (colors?: CustomThemeColors) => {
106
- set({ customColors: colors });
107
- await ThemeStorage.setCustomColors(colors);
108
- useDesignSystemTheme.getState().setCustomColors(colors);
106
+ const { _updateInProgress } = get();
107
+ if (_updateInProgress) return;
108
+ set({ _updateInProgress: true, customColors: colors });
109
+
110
+ try {
111
+ await ThemeStorage.setCustomColors(colors);
112
+ useDesignSystemTheme.getState().setCustomColors(colors);
113
+ } catch {
114
+ // Silent failure
115
+ } finally {
116
+ set({ _updateInProgress: false });
117
+ }
109
118
  },
110
119
 
111
120
  setDefaultColors: (colors: CustomThemeColors) => {
@@ -14,6 +14,7 @@ export class SimpleCache<T> {
14
14
  private cache = new Map<string, CacheEntry<T>>();
15
15
  private defaultTTL: number;
16
16
  private cleanupTimeout: ReturnType<typeof setTimeout> | null = null;
17
+ private destroyed = false;
17
18
 
18
19
  constructor(defaultTTL: number = 60000) {
19
20
  this.defaultTTL = defaultTTL;
@@ -24,6 +25,7 @@ export class SimpleCache<T> {
24
25
  * Destroy the cache and stop cleanup timer
25
26
  */
26
27
  destroy(): void {
28
+ this.destroyed = true;
27
29
  if (this.cleanupTimeout) {
28
30
  clearTimeout(this.cleanupTimeout);
29
31
  this.cleanupTimeout = null;
@@ -32,6 +34,7 @@ export class SimpleCache<T> {
32
34
  }
33
35
 
34
36
  set(key: string, value: T, ttl?: number): void {
37
+ if (this.destroyed) return;
35
38
  const expires = Date.now() + (ttl ?? this.defaultTTL);
36
39
  this.cache.set(key, { value, expires });
37
40
  }
@@ -73,6 +76,8 @@ export class SimpleCache<T> {
73
76
  }
74
77
 
75
78
  private scheduleCleanup(): void {
79
+ if (this.destroyed) return;
80
+
76
81
  if (this.cleanupTimeout) {
77
82
  clearTimeout(this.cleanupTimeout);
78
83
  }
@@ -79,13 +79,10 @@ export function useAsyncOperation<T, E = Error>(
79
79
  const operationRef = useRef(operation);
80
80
  const errorHandlerRef = useRef(errorHandler);
81
81
 
82
- useEffect(() => {
83
- onSuccessRef.current = onSuccess;
84
- onErrorRef.current = onError;
85
- onFinallyRef.current = onFinally;
86
- }, [onSuccess, onError, onFinally]);
87
-
88
- // Keep operation and errorHandler in refs so execute doesn't need them as deps
82
+ // Keep all callback refs in sync with latest values
83
+ onSuccessRef.current = onSuccess;
84
+ onErrorRef.current = onError;
85
+ onFinallyRef.current = onFinally;
89
86
  operationRef.current = operation;
90
87
  errorHandlerRef.current = errorHandler;
91
88