@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.
Files changed (61) hide show
  1. package/package.json +36 -8
  2. package/src/atoms/AtomicAvatar.tsx +69 -40
  3. package/src/atoms/AtomicSpinner.tsx +24 -22
  4. package/src/atoms/AtomicText.tsx +32 -27
  5. package/src/atoms/AtomicTextArea.tsx +17 -15
  6. package/src/atoms/EmptyState.tsx +45 -42
  7. package/src/atoms/button/AtomicButton.tsx +8 -9
  8. package/src/atoms/card/AtomicCard.tsx +26 -8
  9. package/src/atoms/datepicker/components/DatePickerButton.tsx +8 -8
  10. package/src/atoms/datepicker/components/DatePickerModal.tsx +7 -7
  11. package/src/atoms/fab/styles/fabStyles.ts +1 -22
  12. package/src/atoms/icon/index.ts +6 -20
  13. package/src/atoms/picker/components/PickerModal.tsx +24 -4
  14. package/src/atoms/skeleton/AtomicSkeleton.tsx +9 -11
  15. package/src/carousel/Carousel.tsx +43 -20
  16. package/src/carousel/carouselCalculations.ts +12 -9
  17. package/src/carousel/index.ts +0 -1
  18. package/src/device/detection/iPadDetection.ts +5 -14
  19. package/src/device/infrastructure/services/DeviceFeatureService.ts +89 -9
  20. package/src/device/infrastructure/services/DeviceInfoService.ts +33 -0
  21. package/src/device/infrastructure/services/UserFriendlyIdService.ts +8 -6
  22. package/src/device/infrastructure/utils/__tests__/stringUtils.test.ts +56 -20
  23. package/src/device/infrastructure/utils/nativeModuleUtils.ts +16 -2
  24. package/src/device/infrastructure/utils/stringUtils.ts +51 -5
  25. package/src/filesystem/domain/utils/FileUtils.ts +5 -1
  26. package/src/image/domain/utils/ImageUtils.ts +6 -0
  27. package/src/layouts/AppHeader/AppHeader.tsx +13 -3
  28. package/src/layouts/Container/Container.tsx +19 -1
  29. package/src/layouts/FormLayout/FormLayout.tsx +20 -1
  30. package/src/layouts/Grid/Grid.tsx +34 -4
  31. package/src/layouts/ScreenHeader/ScreenHeader.tsx +4 -0
  32. package/src/layouts/ScreenLayout/ScreenLayout.tsx +42 -3
  33. package/src/molecules/Divider/types.ts +1 -1
  34. package/src/molecules/SearchBar/SearchBar.tsx +28 -24
  35. package/src/molecules/StepHeader/StepHeader.tsx +1 -1
  36. package/src/molecules/StepProgress/StepProgress.tsx +1 -1
  37. package/src/molecules/action-footer/ActionFooter.tsx +33 -32
  38. package/src/molecules/alerts/AlertModal.tsx +36 -20
  39. package/src/molecules/alerts/AlertService.ts +60 -15
  40. package/src/molecules/avatar/Avatar.tsx +48 -40
  41. package/src/molecules/avatar/AvatarGroup.tsx +8 -8
  42. package/src/molecules/bottom-sheet/components/BottomSheet.tsx +1 -1
  43. package/src/molecules/bottom-sheet/components/BottomSheetModal.tsx +1 -1
  44. package/src/molecules/bottom-sheet/components/filter/FilterSheet.tsx +1 -1
  45. package/src/molecules/calendar/infrastructure/utils/DateUtilities.ts +12 -1
  46. package/src/molecules/calendar/presentation/components/CalendarDayCell.tsx +48 -32
  47. package/src/molecules/circular-menu/CircularMenuItem.tsx +1 -1
  48. package/src/molecules/countdown/components/CountdownHeader.tsx +1 -1
  49. package/src/molecules/countdown/components/TimeUnit.tsx +1 -1
  50. package/src/molecules/hero-section/HeroSection.tsx +1 -1
  51. package/src/molecules/icon-grid/IconGrid.tsx +1 -1
  52. package/src/molecules/info-grid/InfoGrid.tsx +6 -4
  53. package/src/molecules/navigation/TabsNavigator.tsx +1 -1
  54. package/src/molecules/navigation/components/NavigationHeader.tsx +1 -1
  55. package/src/organisms/FormContainer.tsx +11 -1
  56. package/src/tanstack/domain/utils/ErrorHelpers.ts +2 -2
  57. package/src/tanstack/domain/utils/MetricsCalculator.ts +6 -1
  58. package/src/theme/core/colors/ColorUtils.ts +7 -4
  59. package/src/utils/formatters/stringFormatter.ts +18 -3
  60. package/src/utils/index.ts +140 -0
  61. 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={[cardStyles.badge, { backgroundColor: tokens.colors.primary }]}>
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={[cardStyles.image, { aspectRatio: imageAspectRatio }]}
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={{ padding: paddingValue }}>
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={{ marginRight: 8 }}
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={{ marginLeft: 8 }}
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(() => StyleSheet.create({
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(() => StyleSheet.create({
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 '../../../utils/responsiveUtils';
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
@@ -1,25 +1,11 @@
1
1
  /**
2
- * @deprecated This icon system is deprecated. Use @umituz/react-native-icons instead.
2
+ * Icon System - AtomicIcon and Related Utilities
3
3
  *
4
- * **Migration Guide:**
5
- * ```tsx
6
- * // Old way (deprecated)
7
- * import { AtomicIcon } from '@umituz/react-native-design-system/atoms';
8
- * <AtomicIcon name="User" size={24} color="text" />
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 data={filteredOptions} keyExtractor={(item: PickerOption) => item.value} renderItem={renderOption} showsVerticalScrollIndicator keyboardShouldPersistTaps="handled" testID={`${testID}-list`} />
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 itemStyles = useMemo(() => StyleSheet.create({
56
- item: {
57
- ...styles.skeleton,
58
- width: (typeof config.width === 'number' ? config.width * multiplier : config.width) as DimensionValue,
59
- height: config.height ? config.height * multiplier : undefined,
60
- borderRadius: config.borderRadius ? config.borderRadius * multiplier : undefined,
61
- marginBottom: config.marginBottom ? config.marginBottom * multiplier : undefined,
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={itemStyles.item} />;
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 type { CarouselProps } from "./types";
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 calculatedItemWidth = itemWidth || calculateItemWidth(spacing);
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
- <CarouselScrollView
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
- spacing={spacing}
41
- itemWidth={calculatedItemWidth}
42
- >
43
- {items.map((item, index) => (
44
- <CarouselItem
45
- key={item.id}
46
- item={item}
47
- itemWidth={calculatedItemWidth}
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
- import { Dimensions } from "react-native";
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 { width: SCREEN_WIDTH } = Dimensions.get("window");
4
-
5
- export const calculateItemWidth = (padding: number = 16): number => {
6
- return SCREEN_WIDTH - padding * 2;
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
- };
@@ -10,7 +10,6 @@ export { useCarouselScroll } from "./useCarouselScroll";
10
10
  export {
11
11
  calculateItemWidth,
12
12
  calculateIndexFromScroll,
13
- getScreenWidth,
14
13
  } from "./carouselCalculations";
15
14
 
16
15
  export { CarouselDots } from "./CarouselDots";
@@ -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 = usage.usageCount < featureConfig.maxUses;
127
+ const isAllowed = totalUsageCount < featureConfig.maxUses;
59
128
  const remainingUses = Math.max(
60
129
  0,
61
- featureConfig.maxUses - usage.usageCount
130
+ featureConfig.maxUses - totalUsageCount
62
131
  );
63
132
 
64
133
  return {
65
134
  isAllowed,
66
135
  remainingUses,
67
- usageCount: usage.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 usage = await this.getFeatureUsage(deviceId, featureName);
144
+ const featureKey = `${deviceId}:${featureName}`;
76
145
 
77
- const updatedUsage: DeviceFeatureUsage = {
78
- ...usage,
79
- usageCount: usage.usageCount + 1,
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
- await this.setFeatureUsage(deviceId, featureName, updatedUsage);
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(