@umituz/react-native-design-system 4.28.10 → 4.28.12
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 +36 -8
- package/src/atoms/AtomicAvatar.tsx +69 -40
- package/src/atoms/AtomicSpinner.tsx +24 -22
- package/src/atoms/AtomicText.tsx +32 -27
- package/src/atoms/AtomicTextArea.tsx +17 -15
- package/src/atoms/EmptyState.tsx +45 -42
- package/src/atoms/button/AtomicButton.tsx +8 -9
- package/src/atoms/card/AtomicCard.tsx +26 -8
- package/src/atoms/datepicker/components/DatePickerButton.tsx +8 -8
- package/src/atoms/datepicker/components/DatePickerModal.tsx +7 -7
- package/src/atoms/fab/styles/fabStyles.ts +1 -22
- package/src/atoms/icon/index.ts +6 -20
- package/src/atoms/picker/components/PickerModal.tsx +24 -4
- package/src/atoms/skeleton/AtomicSkeleton.tsx +9 -11
- package/src/carousel/Carousel.tsx +43 -20
- package/src/carousel/carouselCalculations.ts +12 -9
- package/src/carousel/index.ts +0 -1
- package/src/device/detection/iPadDetection.ts +5 -14
- package/src/device/infrastructure/services/DeviceFeatureService.ts +89 -9
- package/src/device/infrastructure/services/DeviceInfoService.ts +33 -0
- package/src/device/infrastructure/services/UserFriendlyIdService.ts +8 -6
- package/src/device/infrastructure/utils/__tests__/stringUtils.test.ts +56 -20
- package/src/device/infrastructure/utils/nativeModuleUtils.ts +16 -2
- package/src/device/infrastructure/utils/stringUtils.ts +51 -5
- package/src/filesystem/domain/utils/FileUtils.ts +5 -1
- package/src/image/domain/utils/ImageUtils.ts +6 -0
- package/src/layouts/AppHeader/AppHeader.tsx +13 -3
- package/src/layouts/Container/Container.tsx +19 -1
- package/src/layouts/FormLayout/FormLayout.tsx +20 -1
- package/src/layouts/Grid/Grid.tsx +34 -4
- package/src/layouts/ScreenHeader/ScreenHeader.tsx +4 -0
- package/src/layouts/ScreenLayout/ScreenLayout.tsx +42 -3
- package/src/molecules/Divider/types.ts +1 -1
- package/src/molecules/SearchBar/SearchBar.tsx +28 -24
- package/src/molecules/StepHeader/StepHeader.tsx +1 -1
- package/src/molecules/StepProgress/StepProgress.tsx +1 -1
- package/src/molecules/action-footer/ActionFooter.tsx +33 -32
- package/src/molecules/alerts/AlertModal.tsx +36 -20
- package/src/molecules/alerts/AlertService.ts +60 -15
- package/src/molecules/avatar/Avatar.tsx +48 -40
- package/src/molecules/avatar/AvatarGroup.tsx +8 -8
- package/src/molecules/bottom-sheet/components/BottomSheet.tsx +1 -1
- package/src/molecules/bottom-sheet/components/BottomSheetModal.tsx +1 -1
- package/src/molecules/bottom-sheet/components/filter/FilterSheet.tsx +1 -1
- package/src/molecules/calendar/infrastructure/utils/DateUtilities.ts +12 -1
- package/src/molecules/calendar/presentation/components/CalendarDayCell.tsx +48 -32
- package/src/molecules/circular-menu/CircularMenuItem.tsx +1 -1
- package/src/molecules/countdown/components/CountdownHeader.tsx +1 -1
- package/src/molecules/countdown/components/TimeUnit.tsx +1 -1
- package/src/molecules/hero-section/HeroSection.tsx +1 -1
- package/src/molecules/icon-grid/IconGrid.tsx +1 -1
- package/src/molecules/info-grid/InfoGrid.tsx +6 -4
- package/src/molecules/navigation/TabsNavigator.tsx +1 -1
- package/src/molecules/navigation/components/NavigationHeader.tsx +1 -1
- package/src/organisms/FormContainer.tsx +11 -1
- package/src/tanstack/domain/utils/ErrorHelpers.ts +2 -2
- package/src/tanstack/domain/utils/MetricsCalculator.ts +6 -1
- package/src/theme/core/colors/ColorUtils.ts +7 -4
- package/src/utils/formatters/stringFormatter.ts +18 -3
- package/src/utils/index.ts +140 -0
- package/src/utils/math/CalculationUtils.ts +10 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Replaces InfoCard, MediaCard, and GlowingCard molecules.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useMemo } from 'react';
|
|
8
|
+
import React, { useMemo, useCallback } from 'react';
|
|
9
9
|
import {
|
|
10
10
|
View,
|
|
11
11
|
Pressable,
|
|
@@ -58,10 +58,28 @@ const CardContent: React.FC<CardContentProps> = React.memo(({
|
|
|
58
58
|
}) => {
|
|
59
59
|
const tokens = useAppDesignTokens();
|
|
60
60
|
|
|
61
|
+
const badgeStyle = useMemo(() => [
|
|
62
|
+
cardStyles.badge,
|
|
63
|
+
{ backgroundColor: tokens.colors.primary },
|
|
64
|
+
], [cardStyles.badge, tokens.colors.primary]);
|
|
65
|
+
|
|
66
|
+
const imageStyle = useMemo(() => [
|
|
67
|
+
cardStyles.image,
|
|
68
|
+
{ aspectRatio: imageAspectRatio },
|
|
69
|
+
], [cardStyles.image, imageAspectRatio]);
|
|
70
|
+
|
|
71
|
+
const contentContainerStyle = useMemo(() => ({
|
|
72
|
+
padding: paddingValue,
|
|
73
|
+
}), [paddingValue]);
|
|
74
|
+
|
|
75
|
+
const leftIconStyle = useMemo(() => ({ marginRight: 8 }), []);
|
|
76
|
+
|
|
77
|
+
const rightIconStyle = useMemo(() => ({ marginLeft: 8 }), []);
|
|
78
|
+
|
|
61
79
|
return (
|
|
62
80
|
<>
|
|
63
81
|
{badge && (
|
|
64
|
-
<View style={
|
|
82
|
+
<View style={badgeStyle}>
|
|
65
83
|
<AtomicText type="labelSmall" color="onPrimary">
|
|
66
84
|
{badge}
|
|
67
85
|
</AtomicText>
|
|
@@ -71,7 +89,7 @@ const CardContent: React.FC<CardContentProps> = React.memo(({
|
|
|
71
89
|
{image && (
|
|
72
90
|
<AtomicImage
|
|
73
91
|
source={typeof image === 'string' ? { uri: image } : image}
|
|
74
|
-
style={
|
|
92
|
+
style={imageStyle}
|
|
75
93
|
contentFit="cover"
|
|
76
94
|
/>
|
|
77
95
|
)}
|
|
@@ -82,7 +100,7 @@ const CardContent: React.FC<CardContentProps> = React.memo(({
|
|
|
82
100
|
</View>
|
|
83
101
|
)}
|
|
84
102
|
|
|
85
|
-
<View style={
|
|
103
|
+
<View style={contentContainerStyle}>
|
|
86
104
|
{(title || leftIcon || rightIcon) && (
|
|
87
105
|
<View style={cardStyles.header}>
|
|
88
106
|
{leftIcon && (
|
|
@@ -90,7 +108,7 @@ const CardContent: React.FC<CardContentProps> = React.memo(({
|
|
|
90
108
|
name={leftIcon}
|
|
91
109
|
size="sm"
|
|
92
110
|
color="primary"
|
|
93
|
-
style={
|
|
111
|
+
style={leftIconStyle}
|
|
94
112
|
/>
|
|
95
113
|
)}
|
|
96
114
|
<View style={cardStyles.titleContainer}>
|
|
@@ -117,7 +135,7 @@ const CardContent: React.FC<CardContentProps> = React.memo(({
|
|
|
117
135
|
name={rightIcon}
|
|
118
136
|
size="sm"
|
|
119
137
|
color="textSecondary"
|
|
120
|
-
style={
|
|
138
|
+
style={rightIconStyle}
|
|
121
139
|
/>
|
|
122
140
|
)}
|
|
123
141
|
</View>
|
|
@@ -190,11 +208,11 @@ const AtomicCardComponent: React.FC<AtomicCardProps> = ({
|
|
|
190
208
|
style,
|
|
191
209
|
], [tokens.borders.radius.lg, variantStyles.container, selected, tokens.colors.primary, style]);
|
|
192
210
|
|
|
193
|
-
const handlePress = (event: GestureResponderEvent) => {
|
|
211
|
+
const handlePress = useCallback((event: GestureResponderEvent) => {
|
|
194
212
|
if (!disabled && onPress) {
|
|
195
213
|
onPress(event);
|
|
196
214
|
}
|
|
197
|
-
};
|
|
215
|
+
}, [disabled, onPress]);
|
|
198
216
|
|
|
199
217
|
const content = (
|
|
200
218
|
<CardContent
|
|
@@ -35,11 +35,11 @@ export const DatePickerButton: React.FC<DatePickerButtonProps> = ({
|
|
|
35
35
|
const tokens = useAppDesignTokens();
|
|
36
36
|
const calendarIcon = useIconName('calendar');
|
|
37
37
|
|
|
38
|
-
const buttonStyles = useMemo(() =>
|
|
38
|
+
const buttonStyles = useMemo(() => ({
|
|
39
39
|
container: {
|
|
40
|
-
flexDirection: 'row',
|
|
41
|
-
alignItems: 'center',
|
|
42
|
-
justifyContent: 'space-between',
|
|
40
|
+
flexDirection: 'row' as const,
|
|
41
|
+
alignItems: 'center' as const,
|
|
42
|
+
justifyContent: 'space-between' as const,
|
|
43
43
|
paddingHorizontal: tokens.spacing.md,
|
|
44
44
|
paddingVertical: tokens.spacing.sm,
|
|
45
45
|
borderRadius: tokens.borders.radius.md,
|
|
@@ -71,18 +71,18 @@ export const DatePickerButton: React.FC<DatePickerButtonProps> = ({
|
|
|
71
71
|
fontWeight: '500',
|
|
72
72
|
},
|
|
73
73
|
iconContainer: {
|
|
74
|
-
flexDirection: 'row',
|
|
75
|
-
alignItems: 'center',
|
|
74
|
+
flexDirection: 'row' as const,
|
|
75
|
+
alignItems: 'center' as const,
|
|
76
76
|
gap: tokens.spacing.xs,
|
|
77
77
|
},
|
|
78
78
|
}), [tokens]);
|
|
79
79
|
|
|
80
|
-
const containerStyle = [
|
|
80
|
+
const containerStyle = useMemo(() => [
|
|
81
81
|
buttonStyles.container,
|
|
82
82
|
error ? buttonStyles.containerError :
|
|
83
83
|
disabled ? buttonStyles.containerDisabled :
|
|
84
84
|
buttonStyles.containerDefault,
|
|
85
|
-
];
|
|
85
|
+
], [buttonStyles, error, disabled]);
|
|
86
86
|
|
|
87
87
|
const textStyle = hasValue ? buttonStyles.valueText : buttonStyles.placeholderText;
|
|
88
88
|
|
|
@@ -64,11 +64,11 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
64
64
|
const tokens = useAppDesignTokens();
|
|
65
65
|
const insets = useSafeAreaInsets();
|
|
66
66
|
|
|
67
|
-
const modalStyles = useMemo(() =>
|
|
67
|
+
const modalStyles = useMemo(() => ({
|
|
68
68
|
overlay: {
|
|
69
69
|
flex: 1,
|
|
70
70
|
backgroundColor: `rgba(0, 0, 0, ${overlayOpacity})`,
|
|
71
|
-
justifyContent: 'flex-end',
|
|
71
|
+
justifyContent: 'flex-end' as const,
|
|
72
72
|
},
|
|
73
73
|
container: {
|
|
74
74
|
backgroundColor: tokens.colors.surface,
|
|
@@ -77,9 +77,9 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
77
77
|
paddingBottom: insets.bottom,
|
|
78
78
|
},
|
|
79
79
|
header: {
|
|
80
|
-
flexDirection: 'row',
|
|
81
|
-
justifyContent: 'space-between',
|
|
82
|
-
alignItems: 'center',
|
|
80
|
+
flexDirection: 'row' as const,
|
|
81
|
+
justifyContent: 'space-between' as const,
|
|
82
|
+
alignItems: 'center' as const,
|
|
83
83
|
paddingHorizontal: tokens.spacing.md,
|
|
84
84
|
paddingVertical: tokens.spacing.sm,
|
|
85
85
|
borderBottomWidth: 1,
|
|
@@ -87,7 +87,7 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
87
87
|
},
|
|
88
88
|
title: {
|
|
89
89
|
fontSize: tokens.typography.titleLarge.fontSize,
|
|
90
|
-
fontWeight: '600',
|
|
90
|
+
fontWeight: '600' as const,
|
|
91
91
|
color: tokens.colors.onSurface,
|
|
92
92
|
},
|
|
93
93
|
doneButton: {
|
|
@@ -98,7 +98,7 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
98
98
|
},
|
|
99
99
|
doneButtonText: {
|
|
100
100
|
fontSize: tokens.typography.labelMedium.fontSize,
|
|
101
|
-
fontWeight: '500',
|
|
101
|
+
fontWeight: '500' as const,
|
|
102
102
|
color: tokens.colors.onPrimary,
|
|
103
103
|
},
|
|
104
104
|
}), [overlayOpacity, tokens, insets.bottom]);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ViewStyle } from 'react-native';
|
|
9
9
|
import type { FabSizeConfig, FabVariantConfig } from '../types';
|
|
10
10
|
import { FAB_SIZES as BASE_FAB_SIZES } from '../../../constants';
|
|
11
|
-
import { calculateResponsiveSize } from '../../../
|
|
11
|
+
import { calculateResponsiveSize } from '../../../responsive';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Get responsive FAB sizes based on spacing multiplier
|
|
@@ -35,27 +35,6 @@ export function getFabSizes(spacingMultiplier: number): Record<'sm' | 'md' | 'lg
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
* @deprecated Use getFabSizes(spacingMultiplier) instead
|
|
40
|
-
*/
|
|
41
|
-
export const FAB_SIZES: Record<'sm' | 'md' | 'lg', FabSizeConfig> = {
|
|
42
|
-
sm: {
|
|
43
|
-
width: BASE_FAB_SIZES.sm,
|
|
44
|
-
height: BASE_FAB_SIZES.sm,
|
|
45
|
-
borderRadius: 12,
|
|
46
|
-
},
|
|
47
|
-
md: {
|
|
48
|
-
width: BASE_FAB_SIZES.md,
|
|
49
|
-
height: BASE_FAB_SIZES.md,
|
|
50
|
-
borderRadius: 16,
|
|
51
|
-
},
|
|
52
|
-
lg: {
|
|
53
|
-
width: BASE_FAB_SIZES.lg,
|
|
54
|
-
height: BASE_FAB_SIZES.lg,
|
|
55
|
-
borderRadius: 20,
|
|
56
|
-
},
|
|
57
|
-
} as const;
|
|
58
|
-
|
|
59
38
|
/**
|
|
60
39
|
* Get FAB variant configurations based on design tokens
|
|
61
40
|
* @param tokens - Design tokens from theme
|
package/src/atoms/icon/index.ts
CHANGED
|
@@ -1,25 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Icon System - AtomicIcon and Related Utilities
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* // New way (recommended)
|
|
11
|
-
* import { Icon } from '@umituz/react-native-icons';
|
|
12
|
-
* <Icon name="User" size={24} color="text" />
|
|
13
|
-
* ```
|
|
14
|
-
*
|
|
15
|
-
* **Benefits of @umituz/react-native-icons:**
|
|
16
|
-
* - ✅ Package-agnostic (works with Lucide, Expo, or custom providers)
|
|
17
|
-
* - ✅ Lazy loading (only configured provider is loaded)
|
|
18
|
-
* - ✅ Automatic name normalization (kebab ↔ PascalCase ↔ camelCase)
|
|
19
|
-
* - ✅ Better performance with caching
|
|
20
|
-
* - ✅ Zero configuration needed in DesignSystemProvider
|
|
21
|
-
*
|
|
22
|
-
* This module is kept for backward compatibility only and will be removed in a future version.
|
|
4
|
+
* Provides icon components with:
|
|
5
|
+
* - Configurable icon rendering (Lucide, Expo Vector Icons, custom)
|
|
6
|
+
* - Size presets (xs, sm, md, lg, xl, xxl) and custom sizes
|
|
7
|
+
* - Color tokens integration
|
|
8
|
+
* - Icon name normalization and caching
|
|
23
9
|
*/
|
|
24
10
|
|
|
25
11
|
// Main Component
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* PickerModal - Selection modal for AtomicPicker
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import React, { useCallback } from 'react';
|
|
5
|
+
import React, { useCallback, useMemo } from 'react';
|
|
6
6
|
import { View, Modal, FlatList, TextInput, TouchableOpacity } from 'react-native';
|
|
7
7
|
import { useSafeAreaInsets } from '../../../safe-area';
|
|
8
8
|
import { useAppDesignTokens } from '../../../theme';
|
|
@@ -60,7 +60,7 @@ export const PickerModal: React.FC<PickerModalProps> = React.memo(({
|
|
|
60
60
|
const insets = useSafeAreaInsets();
|
|
61
61
|
const icons = { checkCircle: useIconName('checkCircle'), search: useIconName('search'), close: useIconName('close'), info: useIconName('info') };
|
|
62
62
|
|
|
63
|
-
const styles = {
|
|
63
|
+
const styles = useMemo(() => ({
|
|
64
64
|
overlay: getModalOverlayStyles(),
|
|
65
65
|
container: getModalContainerStyles(tokens, 0),
|
|
66
66
|
header: getModalHeaderStyles(tokens),
|
|
@@ -69,10 +69,18 @@ export const PickerModal: React.FC<PickerModalProps> = React.memo(({
|
|
|
69
69
|
searchInput: getSearchInputStyles(tokens),
|
|
70
70
|
empty: getEmptyStateStyles(tokens),
|
|
71
71
|
emptyText: getEmptyStateTextStyles(tokens),
|
|
72
|
-
};
|
|
72
|
+
}), [tokens]);
|
|
73
73
|
|
|
74
74
|
const isSelected = useCallback((value: string) => selectedValues?.includes(value) ?? false, [selectedValues]);
|
|
75
75
|
|
|
76
|
+
const OPTION_HEIGHT = 56; // Approximate height of each option
|
|
77
|
+
|
|
78
|
+
const getItemLayout = useCallback((_: unknown, index: number) => ({
|
|
79
|
+
length: OPTION_HEIGHT,
|
|
80
|
+
offset: OPTION_HEIGHT * index,
|
|
81
|
+
index,
|
|
82
|
+
}), []);
|
|
83
|
+
|
|
76
84
|
const renderOption = useCallback(({ item }: { item: PickerOption }) => {
|
|
77
85
|
const selected = isSelected(item.value);
|
|
78
86
|
const disabled = item.disabled || false;
|
|
@@ -117,7 +125,19 @@ export const PickerModal: React.FC<PickerModalProps> = React.memo(({
|
|
|
117
125
|
)}
|
|
118
126
|
|
|
119
127
|
{filteredOptions.length > 0 ? (
|
|
120
|
-
<FlatList
|
|
128
|
+
<FlatList
|
|
129
|
+
data={filteredOptions}
|
|
130
|
+
keyExtractor={(item: PickerOption) => item.value}
|
|
131
|
+
renderItem={renderOption}
|
|
132
|
+
getItemLayout={getItemLayout}
|
|
133
|
+
windowSize={5}
|
|
134
|
+
initialNumToRender={10}
|
|
135
|
+
maxToRenderPerBatch={5}
|
|
136
|
+
removeClippedSubviews
|
|
137
|
+
showsVerticalScrollIndicator={false}
|
|
138
|
+
keyboardShouldPersistTaps="handled"
|
|
139
|
+
testID={`${testID}-list`}
|
|
140
|
+
/>
|
|
121
141
|
) : (
|
|
122
142
|
<View style={styles.empty}>
|
|
123
143
|
<AtomicIcon name={icons.info} size="xl" color="secondary" />
|
|
@@ -44,7 +44,7 @@ export interface AtomicSkeletonProps {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Skeleton loader component
|
|
47
|
-
*
|
|
47
|
+
*
|
|
48
48
|
* Provides visual feedback during content loading with customizable patterns
|
|
49
49
|
*/
|
|
50
50
|
const SkeletonItem: React.FC<{
|
|
@@ -52,18 +52,16 @@ const SkeletonItem: React.FC<{
|
|
|
52
52
|
baseColor: string;
|
|
53
53
|
multiplier: number;
|
|
54
54
|
}> = React.memo(({ config, baseColor, multiplier }) => {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
backgroundColor: baseColor,
|
|
63
|
-
},
|
|
55
|
+
const itemStyle = useMemo<ViewStyle>(() => ({
|
|
56
|
+
...styles.skeleton,
|
|
57
|
+
width: (typeof config.width === 'number' ? config.width * multiplier : config.width) as DimensionValue,
|
|
58
|
+
height: config.height ? config.height * multiplier : undefined,
|
|
59
|
+
borderRadius: config.borderRadius ? config.borderRadius * multiplier : undefined,
|
|
60
|
+
marginBottom: config.marginBottom ? config.marginBottom * multiplier : undefined,
|
|
61
|
+
backgroundColor: baseColor,
|
|
64
62
|
}), [config, baseColor, multiplier]);
|
|
65
63
|
|
|
66
|
-
return <View style={
|
|
64
|
+
return <View style={itemStyle} />;
|
|
67
65
|
});
|
|
68
66
|
|
|
69
67
|
export const AtomicSkeleton: React.FC<AtomicSkeletonProps> = ({
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { View, StyleSheet, ViewStyle } from "react-native";
|
|
1
|
+
import React, { useCallback, useMemo } from "react";
|
|
2
|
+
import { View, StyleSheet, ViewStyle, FlatList } from "react-native";
|
|
3
3
|
import { useAppDesignTokens } from "../theme";
|
|
4
4
|
import { CarouselScrollView } from "./CarouselScrollView";
|
|
5
5
|
import { CarouselDots } from "./CarouselDots";
|
|
6
6
|
import { CarouselItem } from "./CarouselItem";
|
|
7
7
|
import { useCarouselScroll } from "./useCarouselScroll";
|
|
8
8
|
import { calculateItemWidth } from "./carouselCalculations";
|
|
9
|
-
import
|
|
9
|
+
import { useScreenWidth } from "../responsive/useScreenDimensions";
|
|
10
|
+
import type { CarouselProps, CarouselItem as CarouselItemType } from "./types";
|
|
10
11
|
|
|
11
12
|
export const Carousel = <T,>({
|
|
12
13
|
items,
|
|
@@ -19,7 +20,8 @@ export const Carousel = <T,>({
|
|
|
19
20
|
style,
|
|
20
21
|
}: CarouselProps<T> & { style?: ViewStyle }) => {
|
|
21
22
|
const tokens = useAppDesignTokens();
|
|
22
|
-
const
|
|
23
|
+
const screenWidth = useScreenWidth(); // Reactive width
|
|
24
|
+
const calculatedItemWidth = itemWidth || calculateItemWidth(screenWidth, spacing);
|
|
23
25
|
|
|
24
26
|
const pageWidth = calculatedItemWidth + spacing;
|
|
25
27
|
|
|
@@ -32,26 +34,47 @@ export const Carousel = <T,>({
|
|
|
32
34
|
return null;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// Stable key extractor
|
|
38
|
+
const keyExtractor = useCallback((item: CarouselItemType<T>) => item.id, []);
|
|
39
|
+
|
|
40
|
+
// Memoized render item to prevent unnecessary re-renders
|
|
41
|
+
const renderCarouselItem = useCallback(({ item, index }: { item: CarouselItemType<T>; index: number }) => (
|
|
42
|
+
<CarouselItem
|
|
43
|
+
item={item}
|
|
44
|
+
itemWidth={calculatedItemWidth}
|
|
45
|
+
renderContent={(itemData) => renderItem(itemData, index)}
|
|
46
|
+
style={
|
|
47
|
+
index < items.length - 1 ? { marginRight: spacing } : undefined
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
), [calculatedItemWidth, renderItem, items.length, spacing]);
|
|
51
|
+
|
|
52
|
+
// Get item layout for better performance
|
|
53
|
+
const getItemLayout = useCallback((_: unknown, index: number) => ({
|
|
54
|
+
length: calculatedItemWidth + spacing,
|
|
55
|
+
offset: (calculatedItemWidth + spacing) * index,
|
|
56
|
+
index,
|
|
57
|
+
}), [calculatedItemWidth, spacing]);
|
|
58
|
+
|
|
59
|
+
// Use FlatList for virtualization (renders only visible items)
|
|
35
60
|
return (
|
|
36
61
|
<View style={[styles.container, style]}>
|
|
37
|
-
<
|
|
62
|
+
<FlatList
|
|
63
|
+
horizontal
|
|
64
|
+
data={items}
|
|
65
|
+
renderItem={renderCarouselItem}
|
|
66
|
+
keyExtractor={keyExtractor}
|
|
67
|
+
getItemLayout={getItemLayout}
|
|
38
68
|
onScroll={handleScroll}
|
|
39
69
|
pagingEnabled={pagingEnabled}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
renderContent={(itemData) => renderItem(itemData, index)}
|
|
49
|
-
style={
|
|
50
|
-
index < items.length - 1 ? { marginRight: spacing } : undefined
|
|
51
|
-
}
|
|
52
|
-
/>
|
|
53
|
-
))}
|
|
54
|
-
</CarouselScrollView>
|
|
70
|
+
snapToInterval={pageWidth}
|
|
71
|
+
decelerationRate="fast"
|
|
72
|
+
showsHorizontalScrollIndicator={false}
|
|
73
|
+
windowSize={3} // Render 3 screens worth of items
|
|
74
|
+
initialNumToRender={2} // Start with 2 items
|
|
75
|
+
maxToRenderPerBatch={2} // Batch rendering
|
|
76
|
+
removeClippedSubviews
|
|
77
|
+
/>
|
|
55
78
|
|
|
56
79
|
{showDots && items.length > 1 && (
|
|
57
80
|
<CarouselDots
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Carousel Calculations
|
|
3
|
+
*
|
|
4
|
+
* Utility functions for carousel calculations.
|
|
5
|
+
* Note: Pass screenWidth from useScreenWidth() hook to ensure
|
|
6
|
+
* reactivity on orientation changes, iPad Split View, etc.
|
|
7
|
+
*/
|
|
2
8
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
export const calculateItemWidth = (
|
|
10
|
+
screenWidth: number,
|
|
11
|
+
padding: number = 16
|
|
12
|
+
): number => {
|
|
13
|
+
return screenWidth - padding * 2;
|
|
7
14
|
};
|
|
8
15
|
|
|
9
16
|
export const calculateIndexFromScroll = (
|
|
@@ -12,7 +19,3 @@ export const calculateIndexFromScroll = (
|
|
|
12
19
|
): number => {
|
|
13
20
|
return Math.round(scrollPosition / itemWidth);
|
|
14
21
|
};
|
|
15
|
-
|
|
16
|
-
export const getScreenWidth = (): number => {
|
|
17
|
-
return SCREEN_WIDTH;
|
|
18
|
-
};
|
package/src/carousel/index.ts
CHANGED
|
@@ -3,23 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uses expo-device for system-level tablet detection,
|
|
5
5
|
* then uses screen dimensions for iPad-specific sub-categories.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ NOTE: These functions use Dimensions.get() which doesn't update on
|
|
8
|
+
* device rotation, iPad Split View, or Stage Manager. For reactive detection
|
|
9
|
+
* that updates on dimension changes, consider using useWindowDimensions() hook
|
|
10
|
+
* directly in your components.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { Dimensions } from 'react-native';
|
|
9
14
|
import { IPAD_BREAKPOINTS } from './iPadBreakpoints';
|
|
10
15
|
import { isTablet, isLandscape } from './deviceDetection';
|
|
11
16
|
|
|
12
|
-
/**
|
|
13
|
-
* Detect if the current device is an iPad (or Android tablet)
|
|
14
|
-
* Uses expo-device for accurate system-level detection
|
|
15
|
-
*/
|
|
16
17
|
export function isIPad(): boolean {
|
|
17
18
|
return isTablet();
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
* Detect if the current device is an iPad mini
|
|
22
|
-
*/
|
|
23
21
|
export function isIPadMini(): boolean {
|
|
24
22
|
if (!isIPad()) return false;
|
|
25
23
|
|
|
@@ -28,9 +26,6 @@ export function isIPadMini(): boolean {
|
|
|
28
26
|
return minWidth < IPAD_BREAKPOINTS.IPAD_AIR;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Detect if the current device is an iPad Pro (12.9")
|
|
33
|
-
*/
|
|
34
29
|
export function isIPadPro(): boolean {
|
|
35
30
|
if (!isIPad()) return false;
|
|
36
31
|
|
|
@@ -39,10 +34,6 @@ export function isIPadPro(): boolean {
|
|
|
39
34
|
return minWidth >= IPAD_BREAKPOINTS.IPAD_11_PRO;
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
/**
|
|
43
|
-
* Check if tablet device is in landscape orientation
|
|
44
|
-
* Uses shared isLandscape detection for consistency
|
|
45
|
-
*/
|
|
46
37
|
export function isIPadLandscape(): boolean {
|
|
47
38
|
return isLandscape();
|
|
48
39
|
}
|
|
@@ -21,8 +21,70 @@ import { ErrorHandler } from '../../../utils/errors/ErrorHandler';
|
|
|
21
21
|
export class DeviceFeatureService {
|
|
22
22
|
private static config: DeviceFeatureConfig = { features: {} };
|
|
23
23
|
|
|
24
|
+
// In-memory usage tracking for debouncing
|
|
25
|
+
private static inMemoryUsage = new Map<string, number>();
|
|
26
|
+
private static dirtyFeatures = new Set<string>();
|
|
27
|
+
private static flushInterval: ReturnType<typeof setInterval> | null = null;
|
|
28
|
+
private static FLUSH_DELAY = 5000; // 5 seconds
|
|
29
|
+
|
|
24
30
|
static setConfig(config: DeviceFeatureConfig): void {
|
|
25
31
|
this.config = config;
|
|
32
|
+
this.startPeriodicFlush();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start periodic flush of in-memory usage to storage
|
|
37
|
+
*/
|
|
38
|
+
private static startPeriodicFlush(): void {
|
|
39
|
+
if (this.flushInterval) return;
|
|
40
|
+
|
|
41
|
+
this.flushInterval = setInterval(() => {
|
|
42
|
+
this.flushDirtyFeatures();
|
|
43
|
+
}, this.FLUSH_DELAY);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Flush dirty features to storage
|
|
48
|
+
*/
|
|
49
|
+
private static async flushDirtyFeatures(): Promise<void> {
|
|
50
|
+
if (this.dirtyFeatures.size === 0) return;
|
|
51
|
+
|
|
52
|
+
const featuresToFlush = Array.from(this.dirtyFeatures);
|
|
53
|
+
this.dirtyFeatures.clear();
|
|
54
|
+
|
|
55
|
+
for (const featureKey of featuresToFlush) {
|
|
56
|
+
const [deviceId, featureName] = featureKey.split(':');
|
|
57
|
+
const increment = this.inMemoryUsage.get(featureKey) || 0;
|
|
58
|
+
|
|
59
|
+
if (increment > 0) {
|
|
60
|
+
try {
|
|
61
|
+
const usage = await this.getFeatureUsage(deviceId, featureName);
|
|
62
|
+
const updatedUsage: DeviceFeatureUsage = {
|
|
63
|
+
...usage,
|
|
64
|
+
usageCount: usage.usageCount + increment,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await this.setFeatureUsage(deviceId, featureName, updatedUsage);
|
|
68
|
+
this.inMemoryUsage.delete(featureKey);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
ErrorHandler.log(error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stop periodic flush (call on app cleanup)
|
|
78
|
+
*/
|
|
79
|
+
static async destroy(): Promise<void> {
|
|
80
|
+
if (this.flushInterval) {
|
|
81
|
+
clearInterval(this.flushInterval);
|
|
82
|
+
this.flushInterval = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Flush any remaining dirty features
|
|
86
|
+
await this.flushDirtyFeatures();
|
|
87
|
+
this.inMemoryUsage.clear();
|
|
26
88
|
}
|
|
27
89
|
|
|
28
90
|
static async checkFeatureAccess(
|
|
@@ -42,10 +104,17 @@ export class DeviceFeatureService {
|
|
|
42
104
|
}
|
|
43
105
|
|
|
44
106
|
const usage = await this.getFeatureUsage(deviceId, featureName);
|
|
107
|
+
const featureKey = `${deviceId}:${featureName}`;
|
|
108
|
+
const inMemoryIncrement = this.inMemoryUsage.get(featureKey) || 0;
|
|
109
|
+
const totalUsageCount = usage.usageCount + inMemoryIncrement;
|
|
110
|
+
|
|
45
111
|
const shouldReset = this.shouldResetUsage(usage, featureConfig.resetPeriod);
|
|
46
112
|
|
|
47
113
|
if (shouldReset) {
|
|
48
114
|
await this.resetFeatureUsage(deviceId, featureName);
|
|
115
|
+
// Clear in-memory counter on reset
|
|
116
|
+
this.inMemoryUsage.delete(featureKey);
|
|
117
|
+
this.dirtyFeatures.delete(featureKey);
|
|
49
118
|
return {
|
|
50
119
|
isAllowed: true,
|
|
51
120
|
remainingUses: featureConfig.maxUses - 1,
|
|
@@ -55,16 +124,16 @@ export class DeviceFeatureService {
|
|
|
55
124
|
};
|
|
56
125
|
}
|
|
57
126
|
|
|
58
|
-
const isAllowed =
|
|
127
|
+
const isAllowed = totalUsageCount < featureConfig.maxUses;
|
|
59
128
|
const remainingUses = Math.max(
|
|
60
129
|
0,
|
|
61
|
-
featureConfig.maxUses -
|
|
130
|
+
featureConfig.maxUses - totalUsageCount
|
|
62
131
|
);
|
|
63
132
|
|
|
64
133
|
return {
|
|
65
134
|
isAllowed,
|
|
66
135
|
remainingUses,
|
|
67
|
-
usageCount:
|
|
136
|
+
usageCount: totalUsageCount,
|
|
68
137
|
resetAt: this.calculateNextReset(featureConfig.resetPeriod),
|
|
69
138
|
maxUses: featureConfig.maxUses,
|
|
70
139
|
};
|
|
@@ -72,14 +141,25 @@ export class DeviceFeatureService {
|
|
|
72
141
|
|
|
73
142
|
static async incrementFeatureUsage(featureName: string): Promise<void> {
|
|
74
143
|
const deviceId = await PersistentDeviceIdService.getDeviceId();
|
|
75
|
-
const
|
|
144
|
+
const featureKey = `${deviceId}:${featureName}`;
|
|
76
145
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
146
|
+
// Increment in-memory counter
|
|
147
|
+
const currentCount = this.inMemoryUsage.get(featureKey) || 0;
|
|
148
|
+
this.inMemoryUsage.set(featureKey, currentCount + 1);
|
|
149
|
+
|
|
150
|
+
// Mark as dirty for periodic flush
|
|
151
|
+
this.dirtyFeatures.add(featureKey);
|
|
81
152
|
|
|
82
|
-
|
|
153
|
+
// If this is the first increment, fetch current usage and set baseline
|
|
154
|
+
if (currentCount === 0) {
|
|
155
|
+
try {
|
|
156
|
+
const usage = await this.getFeatureUsage(deviceId, featureName);
|
|
157
|
+
// Store baseline to avoid double-counting
|
|
158
|
+
this.inMemoryUsage.set(featureKey, 0);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
ErrorHandler.log(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
83
163
|
}
|
|
84
164
|
|
|
85
165
|
private static async getFeatureUsage(
|