@umituz/react-native-design-system 4.26.6 → 4.26.8

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": "@umituz/react-native-design-system",
3
- "version": "4.26.6",
3
+ "version": "4.26.8",
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 - TanStack persistence and expo-image-manipulator now lazy loaded",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./dist/index.d.ts",
@@ -17,6 +17,7 @@
17
17
  import React, { useMemo } from 'react';
18
18
  import { View, StyleSheet, ViewStyle, DimensionValue, Text } from 'react-native';
19
19
  import { useAppDesignTokens } from '../theme';
20
+ import { normalizeProgress } from '../utils/math';
20
21
 
21
22
  // =============================================================================
22
23
  // TYPE DEFINITIONS
@@ -66,8 +67,11 @@ export const AtomicProgress: React.FC<AtomicProgressProps> = ({
66
67
  }) => {
67
68
  const tokens = useAppDesignTokens();
68
69
 
69
- // Clamp value between 0 and 100
70
- const clampedValue = Math.max(0, Math.min(100, value));
70
+ // Normalize progress value using utility
71
+ const normalizedValue = useMemo(
72
+ () => normalizeProgress(value),
73
+ [value]
74
+ );
71
75
 
72
76
  // Default colors
73
77
  const progressColor = color || tokens.colors.primary;
@@ -75,7 +79,7 @@ export const AtomicProgress: React.FC<AtomicProgressProps> = ({
75
79
  const progressTextColor = textColor || tokens.colors.textPrimary;
76
80
 
77
81
  // Calculate progress width
78
- const progressWidth = `${clampedValue}%`;
82
+ const progressWidth = `${normalizedValue}%`;
79
83
 
80
84
  const scaledHeight = height * tokens.spacingMultiplier;
81
85
 
@@ -109,8 +113,8 @@ export const AtomicProgress: React.FC<AtomicProgressProps> = ({
109
113
  style={[containerStyle, style]}
110
114
  testID={testID}
111
115
  accessibilityRole="progressbar"
112
- accessibilityValue={{ min: 0, max: 100, now: Math.round(clampedValue) }}
113
- accessibilityLabel={`Progress: ${Math.round(clampedValue)}${showPercentage ? '%' : ''}`}
116
+ accessibilityValue={{ min: 0, max: 100, now: Math.round(normalizedValue) }}
117
+ accessibilityLabel={`Progress: ${Math.round(normalizedValue)}${showPercentage ? '%' : ''}`}
114
118
  >
115
119
  <View style={progressStyle} />
116
120
  {(showPercentage || showValue) && (
@@ -118,9 +122,9 @@ export const AtomicProgress: React.FC<AtomicProgressProps> = ({
118
122
  <Text
119
123
  style={textStyle}
120
124
  accessibilityLiveRegion="polite"
121
- accessibilityLabel={`Current progress: ${Math.round(clampedValue)}${showPercentage ? '%' : ''}`}
125
+ accessibilityLabel={`Current progress: ${Math.round(normalizedValue)}${showPercentage ? '%' : ''}`}
122
126
  >
123
- {showPercentage ? `${Math.round(clampedValue)}%` : `${Math.round(clampedValue)}`}
127
+ {showPercentage ? `${Math.round(normalizedValue)}%` : `${Math.round(normalizedValue)}`}
124
128
  </Text>
125
129
  </View>
126
130
  )}
@@ -3,6 +3,7 @@ import { StyleSheet, ViewStyle, StyleProp, View } from 'react-native';
3
3
  // Remove expo-blur import to fix native module error
4
4
  // import { BlurView, BlurTint } from 'expo-blur';
5
5
  import { useDesignSystemTheme } from '../../theme';
6
+ import { intensityToOpacity } from '../../utils/math';
6
7
 
7
8
  // Define a local type for tint to maintain API compatibility
8
9
  export type GlassTint = 'light' | 'dark' | 'default' | 'prominent' | 'regular' | 'extraLight' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterial' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialLight' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark' | 'systemUltraThinMaterialDark';
@@ -17,7 +18,7 @@ export interface GlassViewProps {
17
18
 
18
19
  /**
19
20
  * GlassView
20
- *
21
+ *
21
22
  * A wrapper component for glassmorphism effects.
22
23
  * Currently uses a fallback transparency implementation to avoid native module issues with expo-blur.
23
24
  */
@@ -29,17 +30,15 @@ export const GlassView: React.FC<GlassViewProps> = ({
29
30
  }) => {
30
31
  const { themeMode } = useDesignSystemTheme();
31
32
  const isDark = themeMode === 'dark';
32
-
33
- // Calculate background color opacity based on intensity
34
- // Max intensity 100 -> 0.9 opacity (almost solid)
35
- // Min intensity 0 -> 0.1 opacity (almost transparent)
36
- const opacity = Math.min(0.95, Math.max(0.05, intensity / 100));
37
-
33
+
34
+ // Calculate opacity using utility function
35
+ const opacity = intensityToOpacity(intensity);
36
+
38
37
  // Choose base color based on tint or theme
39
38
  const resolvedTint = tint || (isDark ? 'dark' : 'light');
40
39
  const isDarkBase = resolvedTint === 'dark' || resolvedTint.includes('Dark') || (resolvedTint === 'default' && isDark);
41
-
42
- const backgroundColor = isDarkBase
40
+
41
+ const backgroundColor = isDarkBase
43
42
  ? `rgba(34, 16, 26, ${opacity})` // Dark color from theme
44
43
  : `rgba(255, 255, 255, ${opacity})`;
45
44
 
@@ -1,26 +1,38 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { TouchableOpacity, View } from 'react-native';
3
3
  import { useAppDesignTokens } from '../theme';
4
4
  import { AtomicText, AtomicIcon } from '../atoms';
5
5
  import { ListItemProps } from './listitem/types';
6
6
  import { getListItemStyles } from './listitem/styles/listItemStyles';
7
7
 
8
+ // SVG paths for common icons (work without external icon library)
9
+ const ICON_PATHS: Record<string, string> = {
10
+ 'globe': "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z",
11
+ 'chevron-forward': "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z",
12
+ };
13
+
8
14
  export type { ListItemProps };
9
15
 
10
- export const ListItem: React.FC<ListItemProps> = ({
11
- title, subtitle, leftIcon, rightIcon, onPress, disabled = false, style,
12
- }) => {
16
+ export const ListItem = React.memo<ListItemProps>(({ title, subtitle, leftIcon, rightIcon, onPress, disabled = false, style }) => {
13
17
  const tokens = useAppDesignTokens();
14
18
  const listItemStyles = getListItemStyles(tokens);
15
19
  const Component = onPress ? TouchableOpacity : View;
16
20
 
17
- const accessibilityProps = onPress
18
- ? {
19
- accessibilityRole: 'button' as const,
20
- accessibilityLabel: title,
21
- accessibilityState: { disabled },
22
- }
23
- : {};
21
+ const accessibilityProps = useMemo(
22
+ () => (onPress
23
+ ? {
24
+ accessibilityRole: 'button' as const,
25
+ accessibilityLabel: title,
26
+ accessibilityState: { disabled } as const,
27
+ }
28
+ : {}),
29
+ [onPress, title, disabled]
30
+ );
31
+
32
+ const rightIconStyle = useMemo(
33
+ () => ({ marginLeft: tokens.spacing.md }),
34
+ [tokens.spacing.md]
35
+ );
24
36
 
25
37
  return (
26
38
  <Component
@@ -31,11 +43,12 @@ export const ListItem: React.FC<ListItemProps> = ({
31
43
  {...accessibilityProps}
32
44
  >
33
45
  {leftIcon && (
34
- <AtomicIcon
35
- name={leftIcon}
36
- color={disabled ? 'surfaceVariant' : 'primary'}
37
- size="md"
38
- style={listItemStyles.iconContainer}
46
+ <AtomicIcon
47
+ svgPath={ICON_PATHS[leftIcon] || undefined}
48
+ name={!ICON_PATHS[leftIcon] ? leftIcon : undefined}
49
+ color={disabled ? 'surfaceVariant' : 'primary'}
50
+ size="md"
51
+ style={listItemStyles.iconContainer}
39
52
  />
40
53
  )}
41
54
  <View style={listItemStyles.content}>
@@ -43,13 +56,14 @@ export const ListItem: React.FC<ListItemProps> = ({
43
56
  {subtitle && <AtomicText type="bodySmall" color="surfaceVariant" numberOfLines={2} style={listItemStyles.subtitle}>{subtitle}</AtomicText>}
44
57
  </View>
45
58
  {rightIcon && onPress && (
46
- <AtomicIcon
47
- name={rightIcon}
48
- color="surfaceVariant"
49
- size="sm"
50
- style={{ marginLeft: tokens.spacing.md }}
59
+ <AtomicIcon
60
+ svgPath={ICON_PATHS[rightIcon] || undefined}
61
+ name={!ICON_PATHS[rightIcon] ? rightIcon : undefined}
62
+ color="surfaceVariant"
63
+ size="sm"
64
+ style={rightIconStyle}
51
65
  />
52
66
  )}
53
67
  </Component>
54
68
  );
55
- };
69
+ });
@@ -1,12 +1,53 @@
1
1
 
2
- import React, { useMemo } from 'react';
2
+ import React, { useMemo, useCallback } from 'react';
3
3
  import { View, StyleSheet, TouchableOpacity } from 'react-native';
4
4
  import { AtomicText } from '../../atoms/AtomicText';
5
5
  import { AtomicIcon } from '../../atoms';
6
6
  import { useAppDesignTokens } from '../../theme';
7
7
  import type { ActionFooterProps } from './types';
8
8
 
9
- export const ActionFooter: React.FC<ActionFooterProps> = ({
9
+ const createStyles = () => StyleSheet.create({
10
+ container: {
11
+ flexDirection: 'row',
12
+ alignItems: 'center',
13
+ paddingVertical: 0,
14
+ gap: 0,
15
+ },
16
+ backButton: {
17
+ width: 56,
18
+ height: 56,
19
+ borderRadius: 0,
20
+ backgroundColor: '',
21
+ justifyContent: 'center',
22
+ alignItems: 'center',
23
+ borderWidth: 1,
24
+ borderColor: '',
25
+ },
26
+ actionButton: {
27
+ flex: 1,
28
+ height: 56,
29
+ borderRadius: 0,
30
+ overflow: 'hidden',
31
+ },
32
+ actionContent: {
33
+ flex: 1,
34
+ flexDirection: 'row',
35
+ alignItems: 'center',
36
+ justifyContent: 'flex-start',
37
+ backgroundColor: '',
38
+ gap: 0,
39
+ paddingHorizontal: 0,
40
+ },
41
+ actionText: {
42
+ color: '',
43
+ fontWeight: '800',
44
+ fontSize: 18,
45
+ },
46
+ });
47
+
48
+ const baseStyles = createStyles();
49
+
50
+ export const ActionFooter = React.memo<ActionFooterProps>(({
10
51
  onBack,
11
52
  onAction,
12
53
  actionLabel,
@@ -18,53 +59,51 @@ export const ActionFooter: React.FC<ActionFooterProps> = ({
18
59
  const tokens = useAppDesignTokens();
19
60
 
20
61
  const themedStyles = useMemo(
21
- () =>
22
- StyleSheet.create({
23
- container: {
24
- flexDirection: 'row',
25
- alignItems: 'center',
26
- paddingVertical: tokens.spacing.md,
27
- gap: tokens.spacing.md,
28
- },
29
- backButton: {
30
- width: 56,
31
- height: 56,
32
- borderRadius: tokens.borders.radius.lg,
33
- backgroundColor: tokens.colors.surface,
34
- justifyContent: 'center',
35
- alignItems: 'center',
36
- borderWidth: 1,
37
- borderColor: tokens.colors.outlineVariant,
38
- },
39
- actionButton: {
40
- flex: 1,
41
- height: 56,
42
- borderRadius: tokens.borders.radius.lg,
43
- overflow: 'hidden',
44
- },
45
- actionContent: {
46
- flex: 1,
47
- flexDirection: 'row',
48
- alignItems: 'center',
49
- justifyContent: 'flex-start',
50
- backgroundColor: tokens.colors.primary,
51
- gap: tokens.spacing.sm,
52
- paddingHorizontal: tokens.spacing.lg,
53
- },
54
- actionText: {
55
- color: tokens.colors.onPrimary,
56
- fontWeight: '800',
57
- fontSize: 18,
58
- },
59
- }),
62
+ () => ({
63
+ container: {
64
+ ...baseStyles.container,
65
+ paddingVertical: tokens.spacing.md,
66
+ gap: tokens.spacing.md,
67
+ },
68
+ backButton: {
69
+ ...baseStyles.backButton,
70
+ borderRadius: tokens.borders.radius.lg,
71
+ backgroundColor: tokens.colors.surface,
72
+ borderColor: tokens.colors.outlineVariant,
73
+ },
74
+ actionButton: {
75
+ ...baseStyles.actionButton,
76
+ borderRadius: tokens.borders.radius.lg,
77
+ },
78
+ actionContent: {
79
+ ...baseStyles.actionContent,
80
+ backgroundColor: tokens.colors.primary,
81
+ gap: tokens.spacing.sm,
82
+ paddingHorizontal: tokens.spacing.lg,
83
+ },
84
+ actionText: {
85
+ ...baseStyles.actionText,
86
+ color: tokens.colors.onPrimary,
87
+ },
88
+ }),
60
89
  [tokens],
61
90
  );
62
91
 
92
+ const handleBackPress = useCallback(() => {
93
+ onBack?.();
94
+ }, [onBack]);
95
+
96
+ const handleActionPress = useCallback(() => {
97
+ if (!loading) {
98
+ onAction?.();
99
+ }
100
+ }, [loading, onAction]);
101
+
63
102
  return (
64
103
  <View style={[themedStyles.container, style]}>
65
104
  <TouchableOpacity
66
105
  style={themedStyles.backButton}
67
- onPress={onBack}
106
+ onPress={handleBackPress}
68
107
  activeOpacity={0.7}
69
108
  testID="action-footer-back"
70
109
  accessibilityRole="button"
@@ -79,7 +118,7 @@ export const ActionFooter: React.FC<ActionFooterProps> = ({
79
118
 
80
119
  <TouchableOpacity
81
120
  style={themedStyles.actionButton}
82
- onPress={onAction}
121
+ onPress={handleActionPress}
83
122
  activeOpacity={0.9}
84
123
  disabled={loading}
85
124
  testID="action-footer-action"
@@ -98,4 +137,4 @@ export const ActionFooter: React.FC<ActionFooterProps> = ({
98
137
  </TouchableOpacity>
99
138
  </View>
100
139
  );
101
- };
140
+ });
@@ -19,6 +19,7 @@ import { useAppDesignTokens } from '../../theme';
19
19
  import { AtomicIcon } from '../../atoms';
20
20
  import { AtomicText } from '../../atoms';
21
21
  import type { IconName } from '../../atoms';
22
+ import { calculateGridItemWidth } from '../../utils/math';
22
23
 
23
24
  export interface IconGridItem {
24
25
  /** Unique identifier */
@@ -44,6 +45,48 @@ export interface IconGridProps {
44
45
  style?: StyleProp<ViewStyle>;
45
46
  }
46
47
 
48
+ // Memoized grid item component to prevent unnecessary re-renders
49
+ const GridItem = React.memo<{
50
+ item: IconGridItem;
51
+ itemWidth: number;
52
+ cardBackground: string;
53
+ borderLight: string;
54
+ textPrimary: string;
55
+ }>(({ item, itemWidth, cardBackground, borderLight, textPrimary }) => {
56
+ const cardStyle = useMemo(
57
+ () => [styles.card, { width: itemWidth }],
58
+ [itemWidth]
59
+ );
60
+
61
+ const iconBoxStyle = useMemo(
62
+ () => [
63
+ styles.iconBox,
64
+ { width: itemWidth, backgroundColor: cardBackground, borderColor: borderLight },
65
+ ],
66
+ [itemWidth, cardBackground, borderLight]
67
+ );
68
+
69
+ const labelStyle = useMemo(
70
+ () => [styles.label, { color: textPrimary }],
71
+ [textPrimary]
72
+ );
73
+
74
+ return (
75
+ <TouchableOpacity
76
+ activeOpacity={0.7}
77
+ onPress={item.onPress}
78
+ style={cardStyle}
79
+ >
80
+ <View style={iconBoxStyle}>
81
+ <AtomicIcon name={item.icon} size="lg" color="textPrimary" />
82
+ </View>
83
+ <AtomicText style={labelStyle} numberOfLines={1}>
84
+ {item.label}
85
+ </AtomicText>
86
+ </TouchableOpacity>
87
+ );
88
+ });
89
+
47
90
  /**
48
91
  * A self-sizing icon card grid.
49
92
  *
@@ -58,7 +101,7 @@ export interface IconGridProps {
58
101
  * />
59
102
  * ```
60
103
  */
61
- export const IconGrid: React.FC<IconGridProps> = ({
104
+ export const IconGrid = React.memo<IconGridProps>(({
62
105
  items,
63
106
  columns = 3,
64
107
  gap = 10,
@@ -78,52 +121,43 @@ export const IconGrid: React.FC<IconGridProps> = ({
78
121
  [containerWidth],
79
122
  );
80
123
 
81
- // Total gap space between columns (N columns = N-1 gaps)
124
+ // Calculate item width using utility function
82
125
  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;
126
+ return calculateGridItemWidth(containerWidth, columns, gap);
87
127
  }, [containerWidth, columns, gap]);
88
128
 
89
129
  const { cardBackground, borderLight, textPrimary } = tokens.colors;
90
130
 
131
+ const gridStyle = useMemo(
132
+ () => [styles.grid, { columnGap: gap, rowGap: rowGap ?? gap }, style],
133
+ [gap, rowGap, style]
134
+ );
135
+
136
+ const placeholderStyle = useMemo(
137
+ () => ({ width: 0, height: 0 }),
138
+ []
139
+ );
140
+
91
141
  return (
92
- <View
93
- style={[styles.grid, { columnGap: gap, rowGap: rowGap ?? gap }, style]}
94
- onLayout={handleLayout}
95
- >
142
+ <View style={gridStyle} onLayout={handleLayout}>
96
143
  {items.map((item) =>
97
144
  itemWidth > 0 ? (
98
- <TouchableOpacity
145
+ <GridItem
99
146
  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>
147
+ item={item}
148
+ itemWidth={itemWidth}
149
+ cardBackground={cardBackground}
150
+ borderLight={borderLight}
151
+ textPrimary={textPrimary}
152
+ />
119
153
  ) : (
120
154
  // Placeholder — keeps grid stable before first layout measurement
121
- <View key={item.id} style={{ width: 0, height: 0 }} />
155
+ <View key={item.id} style={placeholderStyle} />
122
156
  ),
123
157
  )}
124
158
  </View>
125
159
  );
126
- };
160
+ });
127
161
 
128
162
  const styles = StyleSheet.create({
129
163
  grid: {
@@ -1,8 +1,9 @@
1
- import React from "react";
1
+ import React, { useMemo, useCallback } from "react";
2
2
  import { View, StyleSheet, TouchableOpacity } from "react-native";
3
3
  import { useSafeAreaInsets } from "react-native-safe-area-context";
4
4
  import { AtomicText } from "../../../atoms/AtomicText";
5
5
  import { useOnboardingProvider } from "../providers/OnboardingProvider";
6
+ import { calculateStepProgress } from "../../../utils/math";
6
7
 
7
8
  export interface OnboardingFooterProps {
8
9
  currentIndex: number;
@@ -15,7 +16,7 @@ export interface OnboardingFooterProps {
15
16
  disabled?: boolean;
16
17
  }
17
18
 
18
- export const OnboardingFooter = ({
19
+ export const OnboardingFooter = React.memo<OnboardingFooterProps>(({
19
20
  currentIndex,
20
21
  totalSlides,
21
22
  isLastSlide,
@@ -24,62 +25,114 @@ export const OnboardingFooter = ({
24
25
  showDots = true,
25
26
  showProgressText = true,
26
27
  disabled = false,
27
- }: OnboardingFooterProps) => {
28
+ }) => {
28
29
  const insets = useSafeAreaInsets();
29
30
  const { theme: { colors }, translations } = useOnboardingProvider();
30
31
 
31
- const buttonText = isLastSlide
32
- ? translations.getStartedButton
33
- : translations.nextButton;
32
+ const buttonText = useMemo(
33
+ () => isLastSlide ? translations.getStartedButton : translations.nextButton,
34
+ [isLastSlide, translations.getStartedButton, translations.nextButton]
35
+ );
36
+
37
+ const progressPercent = useMemo(
38
+ () => calculateStepProgress(currentIndex + 1, totalSlides),
39
+ [currentIndex, totalSlides]
40
+ );
41
+
42
+ const footerStyle = useMemo(
43
+ () => [styles.footer, { paddingBottom: insets.bottom + 24 }],
44
+ [insets.bottom]
45
+ );
46
+
47
+ const progressBarBgStyle = useMemo(
48
+ () => [styles.progressBar, { backgroundColor: colors.progressBarBg }],
49
+ [colors.progressBarBg]
50
+ );
51
+
52
+ const progressFillStyle = useMemo(
53
+ () => ({
54
+ ...styles.progressFill,
55
+ width: `${progressPercent}%` as any,
56
+ backgroundColor: colors.progressFillColor,
57
+ }),
58
+ [progressPercent, colors.progressFillColor]
59
+ );
34
60
 
35
- const progressPercent = ((currentIndex + 1) / totalSlides) * 100;
61
+ const dots = useMemo(
62
+ () => Array.from({ length: totalSlides }, (_, index) => {
63
+ const isActive = index === currentIndex;
64
+ return {
65
+ key: index,
66
+ style: [
67
+ styles.dot,
68
+ { backgroundColor: colors.dotColor },
69
+ isActive && {
70
+ width: 12,
71
+ backgroundColor: colors.activeDotColor
72
+ }
73
+ ]
74
+ };
75
+ }),
76
+ [totalSlides, currentIndex, colors.dotColor, colors.activeDotColor]
77
+ );
78
+
79
+ const buttonStyle = useMemo(
80
+ () => [
81
+ styles.button,
82
+ {
83
+ backgroundColor: colors.buttonBg,
84
+ opacity: disabled ? 0.5 : 1,
85
+ },
86
+ ],
87
+ [colors.buttonBg, disabled]
88
+ );
89
+
90
+ const buttonTextStyle = useMemo(
91
+ () => [styles.buttonText, { color: colors.buttonTextColor }],
92
+ [colors.buttonTextColor]
93
+ );
94
+
95
+ const progressTextStyle = useMemo(
96
+ () => [styles.progressText, { color: colors.progressTextColor }],
97
+ [colors.progressTextColor]
98
+ );
99
+
100
+ const handlePress = useCallback(() => {
101
+ if (!disabled) {
102
+ onNext();
103
+ }
104
+ }, [disabled, onNext]);
36
105
 
37
106
  return (
38
- <View style={[styles.footer, { paddingBottom: insets.bottom + 24 }]}>
107
+ <View style={footerStyle}>
39
108
  {showProgressBar && (
40
109
  <View style={styles.progressContainer}>
41
- <View style={[styles.progressBar, { backgroundColor: colors.progressBarBg }]}>
42
- <View style={[styles.progressFill, { width: `${progressPercent}%`, backgroundColor: colors.progressFillColor }]} />
110
+ <View style={progressBarBgStyle}>
111
+ <View style={progressFillStyle} />
43
112
  </View>
44
113
  </View>
45
114
  )}
46
115
 
47
116
  {showDots && (
48
117
  <View style={styles.dots}>
49
- {Array.from({ length: totalSlides }).map((_, index) => (
50
- <View
51
- key={index}
52
- style={[
53
- styles.dot,
54
- { backgroundColor: colors.dotColor },
55
- index === currentIndex && {
56
- width: 12,
57
- backgroundColor: colors.activeDotColor
58
- }
59
- ]}
60
- />
118
+ {dots.map(({ key, style: dotStyle }) => (
119
+ <View key={key} style={dotStyle} />
61
120
  ))}
62
121
  </View>
63
122
  )}
64
123
 
65
124
  <TouchableOpacity
66
- onPress={onNext}
125
+ onPress={handlePress}
67
126
  disabled={disabled}
68
127
  activeOpacity={0.7}
69
128
  accessibilityRole="button"
70
129
  accessibilityLabel={buttonText}
71
130
  accessibilityState={{ disabled }}
72
- style={[
73
- styles.button,
74
- {
75
- backgroundColor: colors.buttonBg,
76
- opacity: disabled ? 0.5 : 1,
77
- },
78
- ]}
131
+ style={buttonStyle}
79
132
  >
80
133
  <AtomicText
81
134
  type="labelLarge"
82
- style={[styles.buttonText, { color: colors.buttonTextColor }]}
135
+ style={buttonTextStyle}
83
136
  >
84
137
  {buttonText}
85
138
  </AtomicText>
@@ -88,14 +141,14 @@ export const OnboardingFooter = ({
88
141
  {showProgressText && (
89
142
  <AtomicText
90
143
  type="labelSmall"
91
- style={[styles.progressText, { color: colors.progressTextColor }]}
144
+ style={progressTextStyle}
92
145
  >
93
146
  {currentIndex + 1} {translations.of} {totalSlides}
94
147
  </AtomicText>
95
148
  )}
96
149
  </View>
97
150
  );
98
- };
151
+ });
99
152
 
100
153
  const styles = StyleSheet.create({
101
154
  footer: {
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useMemo, useCallback } from "react";
2
2
  import { View, TouchableOpacity, StyleSheet } from "react-native";
3
3
  import { AtomicIcon, useIconName } from "../../../atoms";
4
4
  import { AtomicText } from "../../../atoms/AtomicText";
@@ -13,35 +13,56 @@ export interface OnboardingHeaderProps {
13
13
  skipButtonText?: string;
14
14
  }
15
15
 
16
- export const OnboardingHeader = ({
16
+ export const OnboardingHeader = React.memo<OnboardingHeaderProps>(({
17
17
  isFirstSlide,
18
18
  onBack,
19
19
  onSkip,
20
20
  showBackButton = true,
21
21
  showSkipButton = true,
22
22
  skipButtonText,
23
- }: OnboardingHeaderProps) => {
23
+ }) => {
24
24
  const {
25
25
  theme: { colors },
26
26
  } = useOnboardingProvider();
27
27
  const chevronLeftIcon = useIconName('chevronLeft');
28
28
 
29
+ const handleBack = useCallback(() => {
30
+ if (!isFirstSlide) {
31
+ onBack?.();
32
+ }
33
+ }, [isFirstSlide, onBack]);
34
+
35
+ const backButtonStyle = useMemo(
36
+ () => [
37
+ styles.headerButton,
38
+ {
39
+ backgroundColor: colors.headerButtonBg,
40
+ borderColor: colors.headerButtonBorder,
41
+ },
42
+ isFirstSlide && styles.headerButtonDisabled,
43
+ ],
44
+ [colors.headerButtonBg, colors.headerButtonBorder, isFirstSlide]
45
+ );
46
+
47
+ const skipTextStyle = useMemo(
48
+ () => [styles.skipText, { color: colors.textColor }],
49
+ [colors.textColor]
50
+ );
51
+
52
+ const hitSlop = useMemo(
53
+ () => ({ top: 10, bottom: 10, left: 10, right: 10 }),
54
+ []
55
+ );
56
+
29
57
  return (
30
58
  <View style={styles.header}>
31
59
  {showBackButton ? (
32
60
  <TouchableOpacity
33
- onPress={() => !isFirstSlide && onBack?.()}
61
+ onPress={handleBack}
34
62
  disabled={isFirstSlide}
35
- style={[
36
- styles.headerButton,
37
- {
38
- backgroundColor: colors.headerButtonBg,
39
- borderColor: colors.headerButtonBorder,
40
- },
41
- isFirstSlide && styles.headerButtonDisabled,
42
- ]}
63
+ style={backButtonStyle}
43
64
  activeOpacity={0.7}
44
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
65
+ hitSlop={hitSlop}
45
66
  >
46
67
  <AtomicIcon name={chevronLeftIcon} customSize={20} customColor={colors.iconColor} />
47
68
  </TouchableOpacity>
@@ -54,11 +75,11 @@ export const OnboardingHeader = ({
54
75
  activeOpacity={0.7}
55
76
  accessibilityRole="button"
56
77
  accessibilityLabel={skipButtonText}
57
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
78
+ hitSlop={hitSlop}
58
79
  >
59
80
  <AtomicText
60
81
  type="labelLarge"
61
- style={[styles.skipText, { color: colors.textColor }]}
82
+ style={skipTextStyle}
62
83
  >
63
84
  {skipButtonText}
64
85
  </AtomicText>
@@ -66,7 +87,7 @@ export const OnboardingHeader = ({
66
87
  ) : <View />}
67
88
  </View>
68
89
  );
69
- };
90
+ });
70
91
 
71
92
  const styles = StyleSheet.create({
72
93
  header: {
@@ -161,9 +161,6 @@ export const OnboardingScreen = ({
161
161
  globalUseCustomBackground,
162
162
  });
163
163
 
164
- if (__DEV__) {
165
- }
166
-
167
164
  // Early return if no slides - prevents rendering empty/broken screen
168
165
  if (filteredSlides.length === 0) {
169
166
  if (__DEV__) {
@@ -1,9 +1,38 @@
1
1
  /**
2
2
  * DateFormatter
3
3
  * Handles locale-aware formatting of dates and times
4
+ * Optimized with Intl.DateTimeFormat caching
4
5
  */
5
6
  import { parseDate } from '../utils/TimezoneParsers';
6
7
 
8
+ // Cache for Intl.DateTimeFormat instances to avoid recreating them
9
+ const formatterCache = new Map<string, Intl.DateTimeFormat>();
10
+
11
+ function getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
12
+ const cacheKey = `${locale}-${JSON.stringify(options)}`;
13
+ let formatter = formatterCache.get(cacheKey);
14
+
15
+ if (!formatter) {
16
+ try {
17
+ formatter = new Intl.DateTimeFormat(locale, options);
18
+ } catch (_error) {
19
+ // Fallback to 'en-US' if locale is not supported
20
+ formatter = new Intl.DateTimeFormat('en-US', options);
21
+ }
22
+ formatterCache.set(cacheKey, formatter);
23
+
24
+ // Limit cache size to prevent memory leaks
25
+ if (formatterCache.size > 100) {
26
+ const firstKey = formatterCache.keys().next().value as string;
27
+ if (firstKey) {
28
+ formatterCache.delete(firstKey);
29
+ }
30
+ }
31
+ }
32
+
33
+ return formatter;
34
+ }
35
+
7
36
  export class DateFormatter {
8
37
  formatDate(
9
38
  date: Date | string | number,
@@ -16,7 +45,7 @@ export class DateFormatter {
16
45
  day: 'numeric',
17
46
  ...options,
18
47
  };
19
- return new Intl.DateTimeFormat(locale, defaultOptions).format(this.parse(date));
48
+ return getFormatter(locale, defaultOptions).format(this.parse(date));
20
49
  }
21
50
 
22
51
  formatTime(
@@ -29,7 +58,7 @@ export class DateFormatter {
29
58
  minute: '2-digit',
30
59
  ...options,
31
60
  };
32
- return new Intl.DateTimeFormat(locale, defaultOptions).format(this.parse(date));
61
+ return getFormatter(locale, defaultOptions).format(this.parse(date));
33
62
  }
34
63
 
35
64
  formatDateTime(
@@ -45,7 +74,7 @@ export class DateFormatter {
45
74
  minute: '2-digit',
46
75
  ...options,
47
76
  };
48
- return new Intl.DateTimeFormat(locale, defaultOptions).format(this.parse(date));
77
+ return getFormatter(locale, defaultOptions).format(this.parse(date));
49
78
  }
50
79
 
51
80
  formatDateToString(date: Date | string | number): string {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Calculation Utilities
3
+ *
4
+ * Common mathematical calculations used throughout the app
5
+ */
6
+
7
+ /**
8
+ * Clamps a number between a minimum and maximum value
9
+ * @param value - The value to clamp
10
+ * @param min - Minimum allowed value (default: 0)
11
+ * @param max - Maximum allowed value (default: 100)
12
+ * @returns Clamped value
13
+ */
14
+ export function clamp(value: number, min = 0, max = 100): number {
15
+ return Math.max(min, Math.min(max, value));
16
+ }
17
+
18
+ /**
19
+ * Calculates progress percentage
20
+ * @param current - Current value
21
+ * @param total - Total value
22
+ * @returns Percentage (0-100)
23
+ */
24
+ export function calculatePercentage(current: number, total: number): number {
25
+ if (total === 0) return 0;
26
+ return (current / total) * 100;
27
+ }
28
+
29
+ /**
30
+ * Rounds a number to specified decimal places
31
+ * @param value - The value to round
32
+ * @param decimals - Number of decimal places (default: 0)
33
+ * @returns Rounded value
34
+ */
35
+ export function roundTo(value: number, decimals = 0): number {
36
+ const multiplier = Math.pow(10, decimals);
37
+ return Math.round(value * multiplier) / multiplier;
38
+ }
39
+
40
+ /**
41
+ * Converts intensity (0-100) to opacity (0-1)
42
+ * @param intensity - Intensity value (0-100)
43
+ * @param minOpacity - Minimum opacity (default: 0.05)
44
+ * @param maxOpacity - Maximum opacity (default: 0.95)
45
+ * @returns Opacity value (0-1)
46
+ */
47
+ export function intensityToOpacity(
48
+ intensity: number,
49
+ minOpacity = 0.05,
50
+ maxOpacity = 0.95
51
+ ): number {
52
+ const clamped = clamp(intensity, 0, 100);
53
+ return minOpacity + (clamped / 100) * (maxOpacity - minOpacity);
54
+ }
55
+
56
+ /**
57
+ * Calculates grid item width based on container width and columns
58
+ * @param containerWidth - Total container width
59
+ * @param columns - Number of columns
60
+ * @param gap - Gap between items in pixels
61
+ * @returns Item width in pixels
62
+ */
63
+ export function calculateGridItemWidth(
64
+ containerWidth: number,
65
+ columns: number,
66
+ gap: number
67
+ ): number {
68
+ if (containerWidth <= 0) return 0;
69
+ const totalGap = gap * (columns - 1);
70
+ // Subtract 1px safety margin to prevent sub-pixel wrapping
71
+ return Math.floor((containerWidth - totalGap) / columns) - 1;
72
+ }
73
+
74
+ /**
75
+ * Checks if a value is within a range (inclusive)
76
+ * @param value - Value to check
77
+ * @param min - Range minimum
78
+ * @param max - Range maximum
79
+ * @returns True if value is in range
80
+ */
81
+ export function isInRange(value: number, min: number, max: number): boolean {
82
+ return value >= min && value <= max;
83
+ }
84
+
85
+ /**
86
+ * Linear interpolation between two values
87
+ * @param start - Start value
88
+ * @param end - End value
89
+ * @param progress - Progress (0-1)
90
+ * @returns Interpolated value
91
+ */
92
+ export function lerp(start: number, end: number, progress: number): number {
93
+ return start + (end - start) * Math.max(0, Math.min(1, progress));
94
+ }
95
+
96
+ /**
97
+ * Maps a value from one range to another
98
+ * @param value - Value to map
99
+ * @param inMin - Input range minimum
100
+ * @param inMax - Input range maximum
101
+ * @param outMin - Output range minimum
102
+ * @param outMax - Output range maximum
103
+ * @returns Mapped value
104
+ */
105
+ export function mapRange(
106
+ value: number,
107
+ inMin: number,
108
+ inMax: number,
109
+ outMin: number,
110
+ outMax: number
111
+ ): number {
112
+ return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
113
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Opacity Utilities
3
+ *
4
+ * Helper functions for opacity-related calculations
5
+ */
6
+
7
+ /**
8
+ * Converts intensity value to opacity for glassmorphism effects
9
+ * @param intensity - Intensity value (0-100)
10
+ * @param options - Configuration options
11
+ * @returns Opacity value (0-1)
12
+ */
13
+ export function intensityToOpacity(
14
+ intensity: number,
15
+ options: {
16
+ minOpacity?: number;
17
+ maxOpacity?: number;
18
+ invert?: boolean;
19
+ } = {}
20
+ ): number {
21
+ const {
22
+ minOpacity = 0.05,
23
+ maxOpacity = 0.95,
24
+ invert = false
25
+ } = options;
26
+
27
+ const clamped = Math.max(0, Math.min(100, intensity));
28
+ let opacity = minOpacity + (clamped / 100) * (maxOpacity - minOpacity);
29
+
30
+ if (invert) {
31
+ opacity = maxOpacity - opacity + minOpacity;
32
+ }
33
+
34
+ return opacity;
35
+ }
36
+
37
+ /**
38
+ * Creates an RGBA color string with specified opacity
39
+ * @param rgb - RGB color as array [r, g, b]
40
+ * @param opacity - Opacity value (0-1)
41
+ * @returns RGBA color string
42
+ */
43
+ export function createRgbaColor(rgb: [number, number, number], opacity: number): string {
44
+ const [r, g, b] = rgb;
45
+ const clampedOpacity = Math.max(0, Math.min(1, opacity));
46
+ return `rgba(${r}, ${g}, ${b}, ${clampedOpacity})`;
47
+ }
48
+
49
+ /**
50
+ * Calculates opacity based on a ratio (0-1)
51
+ * @param ratio - Ratio value (0-1)
52
+ * @param minOpacity - Minimum opacity (default: 0.1)
53
+ * @param maxOpacity - Maximum opacity (default: 1)
54
+ * @returns Opacity value (0-1)
55
+ */
56
+ export function ratioToOpacity(
57
+ ratio: number,
58
+ minOpacity = 0.1,
59
+ maxOpacity = 1
60
+ ): number {
61
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
62
+ return minOpacity + clampedRatio * (maxOpacity - minOpacity);
63
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Progress Utilities
3
+ *
4
+ * Helper functions for progress-related calculations
5
+ */
6
+
7
+ import { clamp } from './CalculationUtils';
8
+
9
+ /**
10
+ * Validates and clamps a progress value to ensure it's within valid range
11
+ * @param value - Raw progress value
12
+ * @returns Clamped progress value (0-100)
13
+ */
14
+ export function normalizeProgress(value: number): number {
15
+ return clamp(value, 0, 100);
16
+ }
17
+
18
+ /**
19
+ * Formats a progress value as a percentage string
20
+ * @param value - Progress value (0-100)
21
+ * @param decimals - Number of decimal places (default: 0)
22
+ * @returns Formatted percentage string
23
+ */
24
+ export function formatPercentage(value: number, decimals = 0): string {
25
+ const normalized = normalizeProgress(value);
26
+ return `${normalized.toFixed(decimals)}%`;
27
+ }
28
+
29
+ /**
30
+ * Calculates the percentage completed of a multi-step process
31
+ * @param currentStep - Current step (1-indexed)
32
+ * @param totalSteps - Total number of steps
33
+ * @returns Percentage (0-100)
34
+ */
35
+ export function calculateStepProgress(currentStep: number, totalSteps: number): number {
36
+ if (totalSteps <= 0) return 0;
37
+ const normalizedStep = Math.max(0, Math.min(currentStep, totalSteps));
38
+ return (normalizedStep / totalSteps) * 100;
39
+ }
40
+
41
+ /**
42
+ * Checks if progress is complete
43
+ * @param value - Progress value (0-100)
44
+ * @returns True if progress is 100%
45
+ */
46
+ export function isComplete(value: number): boolean {
47
+ return normalizeProgress(value) >= 100;
48
+ }
49
+
50
+ /**
51
+ * Checks if progress has started
52
+ * @param value - Progress value (0-100)
53
+ * @returns True if progress > 0%
54
+ */
55
+ export function hasStarted(value: number): boolean {
56
+ return normalizeProgress(value) > 0;
57
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Math Utilities Index
3
+ */
4
+
5
+ export {
6
+ clamp,
7
+ calculatePercentage,
8
+ roundTo,
9
+ calculateGridItemWidth,
10
+ isInRange,
11
+ lerp,
12
+ mapRange,
13
+ } from './CalculationUtils';
14
+
15
+ export {
16
+ normalizeProgress,
17
+ formatPercentage,
18
+ calculateStepProgress,
19
+ isComplete,
20
+ hasStarted,
21
+ } from './ProgressUtils';
22
+
23
+ export {
24
+ intensityToOpacity,
25
+ createRgbaColor,
26
+ ratioToOpacity,
27
+ } from './OpacityUtils';