@umituz/react-native-design-system 4.26.7 → 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 +1 -1
- package/src/atoms/AtomicProgress.tsx +11 -7
- package/src/atoms/GlassView/GlassView.tsx +8 -9
- package/src/molecules/ListItem.tsx +19 -13
- package/src/molecules/action-footer/ActionFooter.tsx +83 -44
- package/src/molecules/icon-grid/IconGrid.tsx +66 -32
- package/src/onboarding/presentation/components/OnboardingFooter.tsx +86 -33
- package/src/onboarding/presentation/components/OnboardingHeader.tsx +37 -16
- package/src/onboarding/presentation/screens/OnboardingScreen.tsx +0 -3
- package/src/timezone/infrastructure/services/DateFormatter.ts +32 -3
- package/src/utils/math/CalculationUtils.ts +113 -0
- package/src/utils/math/OpacityUtils.ts +63 -0
- package/src/utils/math/ProgressUtils.ts +57 -0
- package/src/utils/math/index.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "4.26.
|
|
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
|
-
//
|
|
70
|
-
const
|
|
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 = `${
|
|
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(
|
|
113
|
-
accessibilityLabel={`Progress: ${Math.round(
|
|
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(
|
|
125
|
+
accessibilityLabel={`Current progress: ${Math.round(normalizedValue)}${showPercentage ? '%' : ''}`}
|
|
122
126
|
>
|
|
123
|
-
{showPercentage ? `${Math.round(
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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,4 +1,4 @@
|
|
|
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';
|
|
@@ -13,20 +13,26 @@ const ICON_PATHS: Record<string, string> = {
|
|
|
13
13
|
|
|
14
14
|
export type { ListItemProps };
|
|
15
15
|
|
|
16
|
-
export const ListItem
|
|
17
|
-
title, subtitle, leftIcon, rightIcon, onPress, disabled = false, style,
|
|
18
|
-
}) => {
|
|
16
|
+
export const ListItem = React.memo<ListItemProps>(({ title, subtitle, leftIcon, rightIcon, onPress, disabled = false, style }) => {
|
|
19
17
|
const tokens = useAppDesignTokens();
|
|
20
18
|
const listItemStyles = getListItemStyles(tokens);
|
|
21
19
|
const Component = onPress ? TouchableOpacity : View;
|
|
22
20
|
|
|
23
|
-
const accessibilityProps =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
);
|
|
30
36
|
|
|
31
37
|
return (
|
|
32
38
|
<Component
|
|
@@ -55,9 +61,9 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|
|
55
61
|
name={!ICON_PATHS[rightIcon] ? rightIcon : undefined}
|
|
56
62
|
color="surfaceVariant"
|
|
57
63
|
size="sm"
|
|
58
|
-
style={
|
|
64
|
+
style={rightIconStyle}
|
|
59
65
|
/>
|
|
60
66
|
)}
|
|
61
67
|
</Component>
|
|
62
68
|
);
|
|
63
|
-
};
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
container
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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={
|
|
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={
|
|
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
|
|
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
|
-
//
|
|
124
|
+
// Calculate item width using utility function
|
|
82
125
|
const itemWidth = useMemo(() => {
|
|
83
|
-
|
|
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
|
-
<
|
|
145
|
+
<GridItem
|
|
99
146
|
key={item.id}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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={
|
|
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
|
-
}
|
|
28
|
+
}) => {
|
|
28
29
|
const insets = useSafeAreaInsets();
|
|
29
30
|
const { theme: { colors }, translations } = useOnboardingProvider();
|
|
30
31
|
|
|
31
|
-
const buttonText =
|
|
32
|
-
? translations.getStartedButton
|
|
33
|
-
|
|
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
|
|
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={
|
|
107
|
+
<View style={footerStyle}>
|
|
39
108
|
{showProgressBar && (
|
|
40
109
|
<View style={styles.progressContainer}>
|
|
41
|
-
<View style={
|
|
42
|
-
<View style={
|
|
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
|
-
{
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
}
|
|
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={
|
|
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={
|
|
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={
|
|
78
|
+
hitSlop={hitSlop}
|
|
58
79
|
>
|
|
59
80
|
<AtomicText
|
|
60
81
|
type="labelLarge"
|
|
61
|
-
style={
|
|
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: {
|
|
@@ -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
|
|
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
|
|
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
|
|
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';
|