@umituz/react-native-design-system 4.25.39 → 4.25.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-design-system",
3
- "version": "4.25.39",
3
+ "version": "4.25.40",
4
4
  "description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive, safe area, exception, infinite scroll, UUID, image, timezone, offline, onboarding, and loading utilities",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -225,6 +225,39 @@
225
225
  "expo-application": {
226
226
  "optional": true
227
227
  },
228
+ "expo-clipboard": {
229
+ "optional": true
230
+ },
231
+ "expo-crypto": {
232
+ "optional": true
233
+ },
234
+ "expo-device": {
235
+ "optional": true
236
+ },
237
+ "expo-haptics": {
238
+ "optional": true
239
+ },
240
+ "expo-image": {
241
+ "optional": true
242
+ },
243
+ "expo-image-manipulator": {
244
+ "optional": true
245
+ },
246
+ "expo-image-picker": {
247
+ "optional": true
248
+ },
249
+ "expo-network": {
250
+ "optional": true
251
+ },
252
+ "expo-secure-store": {
253
+ "optional": true
254
+ },
255
+ "expo-sharing": {
256
+ "optional": true
257
+ },
258
+ "expo-video": {
259
+ "optional": true
260
+ },
228
261
  "@react-native-community/datetimepicker": {
229
262
  "optional": true
230
263
  }
@@ -1,23 +1,56 @@
1
1
  import React from 'react';
2
- import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image';
2
+ import { Image as RNImage, type StyleProp, type ImageStyle } from 'react-native';
3
3
 
4
- export type AtomicImageProps = ExpoImageProps & {
4
+ // Lazy-load expo-image (optional peer dep) — falls back to React Native Image
5
+ let ExpoImage: React.ComponentType<any> | null = null;
6
+ try {
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ ExpoImage = require('expo-image').Image;
9
+ } catch {
10
+ // expo-image not installed — using React Native Image fallback
11
+ }
12
+
13
+ export type AtomicImageProps = {
14
+ source?: any;
15
+ style?: StyleProp<ImageStyle>;
5
16
  rounded?: boolean;
17
+ contentFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
18
+ cachePolicy?: 'none' | 'disk' | 'memory' | 'memory-disk';
19
+ [key: string]: any;
6
20
  };
7
21
 
8
22
  export const AtomicImage: React.FC<AtomicImageProps> = ({
9
23
  style,
10
24
  rounded,
11
25
  contentFit = 'cover',
26
+ cachePolicy,
12
27
  ...props
13
28
  }) => {
29
+ const roundedStyle = rounded ? { borderRadius: 9999 } : undefined;
30
+
31
+ if (ExpoImage) {
32
+ return (
33
+ <ExpoImage
34
+ style={[style, roundedStyle]}
35
+ contentFit={contentFit}
36
+ cachePolicy={cachePolicy}
37
+ {...props}
38
+ />
39
+ );
40
+ }
41
+
42
+ // Fallback: React Native Image
43
+ const resizeModeMap: Record<string, 'cover' | 'contain' | 'stretch' | 'center'> = {
44
+ cover: 'cover',
45
+ contain: 'contain',
46
+ fill: 'stretch',
47
+ none: 'center',
48
+ 'scale-down': 'contain',
49
+ };
14
50
  return (
15
- <ExpoImage
16
- style={[
17
- style,
18
- rounded && { borderRadius: 9999 }
19
- ]}
20
- contentFit={contentFit}
51
+ <RNImage
52
+ style={[style as StyleProp<ImageStyle>, roundedStyle]}
53
+ resizeMode={resizeModeMap[contentFit] ?? 'cover'}
21
54
  {...props}
22
55
  />
23
56
  );
@@ -1,24 +1,28 @@
1
1
  /**
2
2
  * Device Detection Utilities
3
3
  *
4
- * Uses expo-device for primary device type detection (PHONE vs TABLET)
5
- * and screen dimensions for secondary distinctions (small vs large phone).
6
- *
7
- * Benefits:
8
- * - expo-device uses system-level detection on iOS (100% reliable)
9
- * - Uses screen diagonal on Android (more accurate than pixels)
10
- * - Future-proof: new devices automatically detected correctly
4
+ * Uses expo-device (optional) for primary device type detection.
5
+ * Falls back to Platform.isPad + screen dimensions when expo-device is not installed.
11
6
  */
12
7
 
13
- import { Dimensions } from 'react-native';
14
- import * as Device from 'expo-device';
8
+ import { Dimensions, Platform } from 'react-native';
15
9
  import { DEVICE_BREAKPOINTS, LAYOUT_CONSTANTS } from '../../responsive/config';
16
10
  import { validateScreenDimensions } from '../../responsive/validation';
17
11
 
18
- /**
19
- * Device type enum for conditional rendering
20
- * Used for fine-grained phone size distinctions
21
- */
12
+ // Lazy-load expo-device to avoid crash when native module is not available
13
+ let _deviceModule: typeof import('expo-device') | null = null;
14
+
15
+ const getDeviceModule = (): typeof import('expo-device') | null => {
16
+ if (_deviceModule !== null) return _deviceModule;
17
+ try {
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ _deviceModule = require('expo-device') as typeof import('expo-device');
20
+ return _deviceModule;
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
22
26
  export enum DeviceType {
23
27
  SMALL_PHONE = 'SMALL_PHONE',
24
28
  MEDIUM_PHONE = 'MEDIUM_PHONE',
@@ -26,12 +30,8 @@ export enum DeviceType {
26
30
  TABLET = 'TABLET',
27
31
  }
28
32
 
29
- /**
30
- * Get current screen dimensions
31
- */
32
33
  export const getScreenDimensions = () => {
33
34
  const { width, height } = Dimensions.get('window');
34
-
35
35
  try {
36
36
  validateScreenDimensions(width, height);
37
37
  return { width, height };
@@ -40,83 +40,57 @@ export const getScreenDimensions = () => {
40
40
  }
41
41
  };
42
42
 
43
- /**
44
- * Check if current device is a tablet
45
- * Uses expo-device for accurate system-level detection
46
- */
47
43
  export const isTablet = (): boolean => {
48
- return Device.deviceType === Device.DeviceType.TABLET;
44
+ const Device = getDeviceModule();
45
+ if (Device) {
46
+ return Device.deviceType === Device.DeviceType.TABLET;
47
+ }
48
+ // Fallback: Platform.isPad (iOS) or screen width >= 600dp (Android)
49
+ if (Platform.isPad) return true;
50
+ const { width, height } = getScreenDimensions();
51
+ return Math.min(width, height) >= 600;
49
52
  };
50
53
 
51
- /**
52
- * Check if current device is a phone
53
- * Uses expo-device for accurate system-level detection
54
- */
55
54
  export const isPhone = (): boolean => {
56
- return Device.deviceType === Device.DeviceType.PHONE;
55
+ return !isTablet();
57
56
  };
58
57
 
59
- /**
60
- * Check if current device is a small phone (iPhone SE, 13 mini)
61
- * Uses width breakpoint within phone category
62
- */
63
58
  export const isSmallPhone = (offset?: { width: number }): boolean => {
64
59
  if (!isPhone()) return false;
65
60
  const { width } = offset || getScreenDimensions();
66
61
  return width <= DEVICE_BREAKPOINTS.SMALL_PHONE;
67
62
  };
68
63
 
69
- /**
70
- * Check if current device is a large phone (Pro Max, Plus models)
71
- * Uses width breakpoint within phone category
72
- */
73
64
  export const isLargePhone = (offset?: { width: number }): boolean => {
74
65
  if (!isPhone()) return false;
75
66
  const { width } = offset || getScreenDimensions();
76
67
  return width >= DEVICE_BREAKPOINTS.MEDIUM_PHONE;
77
68
  };
78
69
 
79
- /**
80
- * Check if device is in landscape mode
81
- */
82
70
  export const isLandscape = (offset?: { width: number; height: number }): boolean => {
83
71
  const { width, height } = offset || getScreenDimensions();
84
72
  return width > height;
85
73
  };
86
74
 
87
- /**
88
- * Get current device type with fine-grained phone distinctions
89
- * Uses expo-device for PHONE vs TABLET, width for phone size variants
90
- */
91
75
  export const getDeviceType = (offset?: { width: number }): DeviceType => {
92
- // Use expo-device for primary detection
93
76
  if (isTablet()) {
94
77
  return DeviceType.TABLET;
95
78
  }
96
-
97
- // For phones, use width for size variants
98
79
  const { width } = offset || getScreenDimensions();
99
-
100
80
  if (width <= DEVICE_BREAKPOINTS.SMALL_PHONE) {
101
81
  return DeviceType.SMALL_PHONE;
102
82
  } else if (width <= DEVICE_BREAKPOINTS.MEDIUM_PHONE) {
103
83
  return DeviceType.MEDIUM_PHONE;
104
84
  }
105
-
106
85
  return DeviceType.LARGE_PHONE;
107
86
  };
108
87
 
109
- /**
110
- * Responsive spacing multiplier based on device type
111
- */
112
88
  export const getSpacingMultiplier = (offset?: { width: number }): number => {
113
89
  if (isTablet()) {
114
90
  return LAYOUT_CONSTANTS.SPACING_MULTIPLIER_TABLET;
115
91
  }
116
-
117
92
  if (isSmallPhone(offset)) {
118
93
  return LAYOUT_CONSTANTS.SPACING_MULTIPLIER_SMALL;
119
94
  }
120
-
121
95
  return LAYOUT_CONSTANTS.SPACING_MULTIPLIER_STANDARD;
122
96
  };
@@ -1,46 +1,51 @@
1
1
  /**
2
2
  * Device Info Service
3
3
  *
4
- * Single Responsibility: Get device information from native modules
5
- * Follows SOLID principles - only handles device info retrieval
4
+ * Single Responsibility: Get device information from native modules.
5
+ * Uses expo-device (optional peer dep) with safe fallback.
6
6
  */
7
7
 
8
- import * as Device from 'expo-device';
9
8
  import { Platform } from 'react-native';
10
9
  import * as Localization from 'expo-localization';
11
10
  import type { DeviceInfo } from '../../domain/entities/Device';
12
11
  import { safeAccess, withTimeout } from '../utils/nativeModuleUtils';
13
12
 
14
- /**
15
- * Service for retrieving device information
16
- */
13
+ // Lazy-load expo-device to avoid crash when native module is not available
14
+ let _deviceModule: typeof import('expo-device') | null = null;
15
+
16
+ const getDeviceModule = (): typeof import('expo-device') | null => {
17
+ if (_deviceModule !== null) return _deviceModule;
18
+ try {
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ _deviceModule = require('expo-device') as typeof import('expo-device');
21
+ return _deviceModule;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
17
27
  export class DeviceInfoService {
18
- /**
19
- * Get device information
20
- * SAFE: Returns minimal info if native modules are not ready
21
- */
22
28
  static async getDeviceInfo(): Promise<DeviceInfo> {
23
29
  try {
24
- const totalMemoryResult = await withTimeout<number>(
25
- () => Device.getMaxMemoryAsync(),
26
- 1000,
27
- );
28
- const totalMemory: number | null = totalMemoryResult ?? null;
30
+ const Device = getDeviceModule();
31
+
32
+ const totalMemory: number | null = Device
33
+ ? (await withTimeout<number>(() => Device.getMaxMemoryAsync(), 1000)) ?? null
34
+ : null;
29
35
 
30
- const brand = safeAccess(() => Device.brand, null);
31
- const manufacturer = safeAccess(() => Device.manufacturer, null);
32
- const modelName = safeAccess(() => Device.modelName, null);
33
- const modelId = safeAccess(() => Device.modelId, null);
34
- const deviceName = safeAccess(() => Device.deviceName, null);
35
- const deviceYearClass = safeAccess(() => Device.deviceYearClass, null);
36
- const deviceType = safeAccess(() => Device.deviceType, null);
37
- const isDevice = safeAccess(() => Device.isDevice, false);
38
- const osName = safeAccess(() => Device.osName, null);
39
- const osVersion = safeAccess(() => Device.osVersion, null);
40
- const osBuildId = safeAccess(() => Device.osBuildId, null);
41
- const platformApiLevel = safeAccess(() => Device.platformApiLevel, null);
36
+ const brand = Device ? safeAccess(() => Device.brand, null) : null;
37
+ const manufacturer = Device ? safeAccess(() => Device.manufacturer, null) : null;
38
+ const modelName = Device ? safeAccess(() => Device.modelName, null) : null;
39
+ const modelId = Device ? safeAccess(() => Device.modelId, null) : null;
40
+ const deviceName = Device ? safeAccess(() => Device.deviceName, null) : null;
41
+ const deviceYearClass = Device ? safeAccess(() => Device.deviceYearClass, null) : null;
42
+ const deviceType = Device ? safeAccess(() => Device.deviceType, null) : null;
43
+ const isDevice = Device ? safeAccess(() => Device.isDevice, false) : false;
44
+ const osName = Device ? safeAccess(() => Device.osName, null) : null;
45
+ const osVersion = Device ? safeAccess(() => Device.osVersion, null) : null;
46
+ const osBuildId = Device ? safeAccess(() => Device.osBuildId, null) : null;
47
+ const platformApiLevel = Device ? safeAccess(() => Device.platformApiLevel, null) : null;
42
48
 
43
- // Localization
44
49
  const calendars = Localization.getCalendars();
45
50
  const locales = Localization.getLocales();
46
51
  const timezone = calendars?.[0]?.timeZone ?? null;
@@ -69,9 +74,6 @@ export class DeviceInfoService {
69
74
  }
70
75
  }
71
76
 
72
- /**
73
- * Get minimal device info (fallback)
74
- */
75
77
  private static getMinimalDeviceInfo(): DeviceInfo {
76
78
  return {
77
79
  brand: null,
@@ -93,4 +95,3 @@ export class DeviceInfoService {
93
95
  };
94
96
  }
95
97
  }
96
-
@@ -1,34 +1,36 @@
1
1
  /**
2
2
  * Haptics Domain - Haptic Service
3
3
  *
4
- * Service for haptic feedback using expo-haptics.
5
- * Provides abstraction layer for vibration and feedback.
6
- *
7
- * @domain haptics
8
- * @layer infrastructure/services
4
+ * Service for haptic feedback using expo-haptics (optional peer dep).
5
+ * Falls back to noop when expo-haptics is not installed.
9
6
  */
10
7
 
11
- import * as Haptics from 'expo-haptics';
12
8
  import type { ImpactStyle, NotificationType, HapticPattern } from '../../domain/entities/Haptic';
13
9
 
14
- /**
15
- * Log error in development mode only
16
- */
10
+ // Lazy-load expo-haptics to avoid crash when native module is not available
11
+ let _hapticsModule: typeof import('expo-haptics') | null = null;
12
+
13
+ const getHapticsModule = (): typeof import('expo-haptics') | null => {
14
+ if (_hapticsModule !== null) return _hapticsModule;
15
+ try {
16
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
17
+ _hapticsModule = require('expo-haptics') as typeof import('expo-haptics');
18
+ return _hapticsModule;
19
+ } catch {
20
+ return null;
21
+ }
22
+ };
23
+
17
24
  function logError(method: string, error: unknown): void {
18
25
  if (__DEV__) {
19
26
  console.error(`[DesignSystem] HapticService.${method} error:`, error);
20
27
  }
21
28
  }
22
29
 
23
-
24
- /**
25
- * Haptic feedback service
26
- */
27
30
  export class HapticService {
28
- /**
29
- * Trigger impact feedback (Light, Medium, Heavy)
30
- */
31
31
  static async impact(style: ImpactStyle = 'Light'): Promise<void> {
32
+ const Haptics = getHapticsModule();
33
+ if (!Haptics) return;
32
34
  try {
33
35
  await Haptics.impactAsync(
34
36
  style === 'Light' ? Haptics.ImpactFeedbackStyle.Light :
@@ -40,10 +42,9 @@ export class HapticService {
40
42
  }
41
43
  }
42
44
 
43
- /**
44
- * Trigger notification feedback (Success, Warning, Error)
45
- */
46
45
  static async notification(type: NotificationType): Promise<void> {
46
+ const Haptics = getHapticsModule();
47
+ if (!Haptics) return;
47
48
  try {
48
49
  await Haptics.notificationAsync(
49
50
  type === 'Success' ? Haptics.NotificationFeedbackType.Success :
@@ -55,10 +56,9 @@ export class HapticService {
55
56
  }
56
57
  }
57
58
 
58
- /**
59
- * Trigger selection feedback (for pickers, sliders)
60
- */
61
59
  static async selection(): Promise<void> {
60
+ const Haptics = getHapticsModule();
61
+ if (!Haptics) return;
62
62
  try {
63
63
  await Haptics.selectionAsync();
64
64
  } catch (error) {
@@ -66,9 +66,6 @@ export class HapticService {
66
66
  }
67
67
  }
68
68
 
69
- /**
70
- * Trigger haptic pattern
71
- */
72
69
  static async pattern(pattern: HapticPattern): Promise<void> {
73
70
  try {
74
71
  switch (pattern) {
@@ -92,38 +89,12 @@ export class HapticService {
92
89
  }
93
90
  }
94
91
 
95
- /**
96
- * Common haptic patterns (convenience methods)
97
- */
98
- static async buttonPress(): Promise<void> {
99
- await HapticService.impact('Light');
100
- }
101
-
102
- static async success(): Promise<void> {
103
- await HapticService.pattern('success');
104
- }
105
-
106
- static async error(): Promise<void> {
107
- await HapticService.pattern('error');
108
- }
109
-
110
- static async warning(): Promise<void> {
111
- await HapticService.pattern('warning');
112
- }
113
-
114
- static async delete(): Promise<void> {
115
- await HapticService.impact('Medium');
116
- }
117
-
118
- static async refresh(): Promise<void> {
119
- await HapticService.impact('Light');
120
- }
121
-
122
- static async selectionChange(): Promise<void> {
123
- await HapticService.pattern('selection');
124
- }
125
-
126
- static async longPress(): Promise<void> {
127
- await HapticService.impact('Medium');
128
- }
92
+ static async buttonPress(): Promise<void> { await HapticService.impact('Light'); }
93
+ static async success(): Promise<void> { await HapticService.pattern('success'); }
94
+ static async error(): Promise<void> { await HapticService.pattern('error'); }
95
+ static async warning(): Promise<void> { await HapticService.pattern('warning'); }
96
+ static async delete(): Promise<void> { await HapticService.impact('Medium'); }
97
+ static async refresh(): Promise<void> { await HapticService.impact('Light'); }
98
+ static async selectionChange(): Promise<void> { await HapticService.pattern('selection'); }
99
+ static async longPress(): Promise<void> { await HapticService.impact('Medium'); }
129
100
  }
@@ -1,139 +1,145 @@
1
1
  /**
2
2
  * Presentation - Image Gallery Component
3
3
  *
4
- * High-performance, premium image gallery using expo-image.
5
- * Replaces slow standard image components for instant loading.
4
+ * High-performance image gallery.
5
+ * Uses expo-image when available, falls back to React Native Image.
6
6
  */
7
7
 
8
8
  import React, { useCallback, useRef, useMemo } from 'react';
9
- import { Modal, View, StyleSheet, FlatList, useWindowDimensions, type NativeSyntheticEvent, type NativeScrollEvent } from 'react-native';
10
- import { Image } from 'expo-image';
9
+ import { Modal, View, Image as RNImage, StyleSheet, FlatList, useWindowDimensions, type NativeSyntheticEvent, type NativeScrollEvent } from 'react-native';
11
10
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
12
11
  import type { ImageViewerItem, ImageGalleryOptions } from '../../domain/entities/ImageTypes';
13
12
  import { GalleryHeader } from './GalleryHeader';
14
13
 
14
+ // Lazy-load expo-image (optional peer dep)
15
+ let ExpoImage: React.ComponentType<any> | null = null;
16
+ try {
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ ExpoImage = require('expo-image').Image;
19
+ } catch {
20
+ // expo-image not installed — using React Native Image fallback
21
+ }
22
+
15
23
  export interface ImageGalleryProps extends ImageGalleryOptions {
16
- images: ImageViewerItem[];
17
- visible: boolean;
18
- onDismiss: () => void;
19
- index?: number;
20
- onImageChange?: (uri: string, index: number) => void | Promise<void>;
21
- enableEditing?: boolean;
22
- title?: string;
24
+ images: ImageViewerItem[];
25
+ visible: boolean;
26
+ onDismiss: () => void;
27
+ index?: number;
28
+ onImageChange?: (uri: string, index: number) => void | Promise<void>;
29
+ enableEditing?: boolean;
30
+ title?: string;
23
31
  }
24
32
 
25
33
  export const ImageGallery: React.FC<ImageGalleryProps> = ({
26
- images,
27
- visible,
28
- onDismiss,
29
- index = 0,
30
- backgroundColor = '#000000',
31
- onIndexChange,
32
- onImageChange,
33
- enableEditing = false,
34
- title,
34
+ images,
35
+ visible,
36
+ onDismiss,
37
+ index = 0,
38
+ backgroundColor = '#000000',
39
+ onIndexChange,
40
+ onImageChange,
41
+ enableEditing = false,
42
+ title,
35
43
  }) => {
36
- const insets = useSafeAreaInsets();
37
- const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
38
- const currentIndexRef = useRef(index);
39
- const [, forceRender] = React.useReducer((x: number) => x + 1, 0);
40
-
41
- const styles = useMemo(() => StyleSheet.create({
42
- container: {
43
- flex: 1,
44
- },
45
- list: {
46
- flex: 1,
47
- },
48
- imageWrapper: {
49
- width: SCREEN_WIDTH,
50
- height: SCREEN_HEIGHT,
51
- justifyContent: 'center',
52
- alignItems: 'center',
53
- },
54
- fullImage: {
55
- width: '100%',
56
- height: '100%',
57
- },
58
- footer: {
59
- position: 'absolute',
60
- bottom: 0,
61
- left: 0,
62
- right: 0,
63
- alignItems: 'center',
64
- }
65
- }), [SCREEN_WIDTH, SCREEN_HEIGHT]);
44
+ const insets = useSafeAreaInsets();
45
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
46
+ const currentIndexRef = useRef(index);
47
+ const [, forceRender] = React.useReducer((x: number) => x + 1, 0);
66
48
 
67
- if (visible) {
68
- currentIndexRef.current = index;
69
- }
70
-
71
- const handleEdit = useCallback(async () => {
72
- const currentImage = images[currentIndexRef.current];
73
- if (!currentImage || !onImageChange) return;
74
- await onImageChange(currentImage.uri, currentIndexRef.current);
75
- }, [images, onImageChange]);
49
+ const styles = useMemo(() => StyleSheet.create({
50
+ container: { flex: 1 },
51
+ list: { flex: 1 },
52
+ imageWrapper: {
53
+ width: SCREEN_WIDTH,
54
+ height: SCREEN_HEIGHT,
55
+ justifyContent: 'center',
56
+ alignItems: 'center',
57
+ },
58
+ fullImage: { width: '100%', height: '100%' },
59
+ footer: {
60
+ position: 'absolute',
61
+ bottom: 0,
62
+ left: 0,
63
+ right: 0,
64
+ alignItems: 'center',
65
+ },
66
+ }), [SCREEN_WIDTH, SCREEN_HEIGHT]);
76
67
 
77
- const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
78
- const nextIndex = Math.round(event.nativeEvent.contentOffset.x / SCREEN_WIDTH);
79
- if (nextIndex !== currentIndexRef.current) {
80
- currentIndexRef.current = nextIndex;
81
- onIndexChange?.(nextIndex);
82
- forceRender();
83
- }
84
- }, [onIndexChange, SCREEN_WIDTH]);
68
+ if (visible) {
69
+ currentIndexRef.current = index;
70
+ }
85
71
 
86
- const renderItem = useCallback(({ item }: { item: ImageViewerItem }) => (
87
- <View style={styles.imageWrapper}>
88
- <Image
89
- source={{ uri: item.uri }}
90
- style={styles.fullImage}
91
- contentFit="contain"
92
- cachePolicy="memory-disk"
93
- />
94
- </View>
95
- ), [styles]);
72
+ const handleEdit = useCallback(async () => {
73
+ const currentImage = images[currentIndexRef.current];
74
+ if (!currentImage || !onImageChange) return;
75
+ await onImageChange(currentImage.uri, currentIndexRef.current);
76
+ }, [images, onImageChange]);
96
77
 
97
- const getItemLayout = useCallback((_: unknown, i: number) => ({
98
- length: SCREEN_WIDTH,
99
- offset: SCREEN_WIDTH * i,
100
- index: i,
101
- }), [SCREEN_WIDTH]);
78
+ const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
79
+ const nextIndex = Math.round(event.nativeEvent.contentOffset.x / SCREEN_WIDTH);
80
+ if (nextIndex !== currentIndexRef.current) {
81
+ currentIndexRef.current = nextIndex;
82
+ onIndexChange?.(nextIndex);
83
+ forceRender();
84
+ }
85
+ }, [onIndexChange, SCREEN_WIDTH]);
102
86
 
103
- if (!visible) return null;
87
+ const renderItem = useCallback(({ item }: { item: ImageViewerItem }) => (
88
+ <View style={styles.imageWrapper}>
89
+ {ExpoImage ? (
90
+ <ExpoImage
91
+ source={{ uri: item.uri }}
92
+ style={styles.fullImage}
93
+ contentFit="contain"
94
+ cachePolicy="memory-disk"
95
+ />
96
+ ) : (
97
+ <RNImage
98
+ source={{ uri: item.uri }}
99
+ style={styles.fullImage}
100
+ resizeMode="contain"
101
+ />
102
+ )}
103
+ </View>
104
+ ), [styles]);
104
105
 
105
- return (
106
- <Modal
107
- visible={visible}
108
- transparent
109
- animationType="none"
110
- onRequestClose={onDismiss}
111
- statusBarTranslucent
112
- >
113
- <View style={[styles.container, { backgroundColor }]}>
114
- <GalleryHeader
115
- onClose={onDismiss}
116
- onEdit={enableEditing ? handleEdit : undefined}
117
- title={title || `${currentIndexRef.current + 1} / ${images.length}`}
118
- />
106
+ const getItemLayout = useCallback((_: unknown, i: number) => ({
107
+ length: SCREEN_WIDTH,
108
+ offset: SCREEN_WIDTH * i,
109
+ index: i,
110
+ }), [SCREEN_WIDTH]);
119
111
 
120
- <FlatList
121
- data={images}
122
- renderItem={renderItem}
123
- horizontal
124
- pagingEnabled
125
- showsHorizontalScrollIndicator={false}
126
- initialScrollIndex={index}
127
- getItemLayout={getItemLayout}
128
- onScroll={handleScroll}
129
- scrollEventThrottle={16}
130
- keyExtractor={(item, i) => `${item.uri}-${i}`}
131
- style={styles.list}
132
- />
112
+ if (!visible) return null;
133
113
 
134
- <View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 20) }]}>
135
- </View>
136
- </View>
137
- </Modal>
138
- );
114
+ return (
115
+ <Modal
116
+ visible={visible}
117
+ transparent
118
+ animationType="none"
119
+ onRequestClose={onDismiss}
120
+ statusBarTranslucent
121
+ >
122
+ <View style={[styles.container, { backgroundColor }]}>
123
+ <GalleryHeader
124
+ onClose={onDismiss}
125
+ onEdit={enableEditing ? handleEdit : undefined}
126
+ title={title || `${currentIndexRef.current + 1} / ${images.length}`}
127
+ />
128
+ <FlatList
129
+ data={images}
130
+ renderItem={renderItem}
131
+ horizontal
132
+ pagingEnabled
133
+ showsHorizontalScrollIndicator={false}
134
+ initialScrollIndex={index}
135
+ getItemLayout={getItemLayout}
136
+ onScroll={handleScroll}
137
+ scrollEventThrottle={16}
138
+ keyExtractor={(item, i) => `${item.uri}-${i}`}
139
+ style={styles.list}
140
+ />
141
+ <View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 20) }]} />
142
+ </View>
143
+ </Modal>
144
+ );
139
145
  };
@@ -4,7 +4,9 @@
4
4
  * Domain entity representing a single onboarding slide
5
5
  */
6
6
 
7
- import type { VideoSource } from "expo-video";
7
+ // Compatible with expo-video VideoSource (optional peer dep)
8
+ type VideoSource = string | { uri: string; headers?: Record<string, string>; [key: string]: unknown };
9
+
8
10
  import type { ImageSourceType } from "../types/ImageSourceType";
9
11
  import type { OnboardingQuestion, OnboardingAnswerValue } from "./OnboardingQuestion";
10
12
 
@@ -23,132 +25,26 @@ export type ContentPosition = "center" | "bottom";
23
25
  * Each slide represents one step in the onboarding flow
24
26
  */
25
27
  export interface OnboardingSlide {
26
- /**
27
- * Unique identifier for the slide
28
- */
29
28
  id: string;
30
-
31
- /**
32
- * Slide type (default: "info")
33
- */
34
29
  type?: SlideType;
35
-
36
- /**
37
- * Slide title
38
- */
39
30
  title: string;
40
-
41
- /**
42
- * Slide description/body text
43
- */
44
31
  description: string;
45
-
46
- /**
47
- * Icon to display (emoji or icon name)
48
- */
49
32
  icon?: string;
50
-
51
- /**
52
- * Type of icon: 'emoji' or 'icon' (default: 'icon')
53
- */
54
33
  iconType?: 'emoji' | 'icon';
55
-
56
- /**
57
- * Hide icon even if provided (default: false)
58
- */
59
34
  hideIcon?: boolean;
60
-
61
- /**
62
- * Content position: 'center' or 'bottom' (default: 'center')
63
- */
64
35
  contentPosition?: ContentPosition;
65
-
66
- /**
67
- * Background color for the slide background (optional)
68
- * Only used if useCustomBackground is true
69
- */
70
36
  backgroundColor?: string;
71
-
72
- /**
73
- * Use custom background color instead of theme defaults (default: false)
74
- * If true and backgroundColor is provided, it will be used
75
- */
76
37
  useCustomBackground?: boolean;
77
-
78
- /**
79
- * Optional image URL (alternative to icon)
80
- */
81
38
  image?: ImageSourceType;
82
-
83
- /**
84
- * Optional background image (URL or require path)
85
- * Stretches to fill the screen behind content
86
- */
87
39
  backgroundImage?: ImageSourceType;
88
-
89
- /**
90
- * Optional multiple background images (URLs or require paths)
91
- * Displayed in a collage/grid pattern behind content
92
- * If provided, takes precedence over single backgroundImage
93
- */
94
40
  backgroundImages?: ImageSourceType[];
95
-
96
- /**
97
- * Layout pattern for multiple background images
98
- * 'grid' - Equal sized grid (auto columns)
99
- * 'dense' - Dense grid with many small images (6 columns)
100
- * 'masonry' - Pinterest-style masonry layout
101
- * 'collage' - Random sizes and positions
102
- * 'scattered' - Small randomly placed images
103
- * 'tiles' - Fixed size tiles centered
104
- * 'honeycomb' - Hexagonal pattern
105
- * Default: 'grid'
106
- */
107
41
  backgroundImagesLayout?: 'grid' | 'dense' | 'masonry' | 'collage' | 'scattered' | 'tiles' | 'honeycomb';
108
-
109
- /**
110
- * Number of columns for grid-based layouts
111
- * Only applies to: grid, dense, masonry, tiles
112
- */
113
42
  backgroundImagesColumns?: number;
114
-
115
- /**
116
- * Gap between images in pixels
117
- */
118
43
  backgroundImagesGap?: number;
119
-
120
- /**
121
- * Border radius for images
122
- */
123
44
  backgroundImagesBorderRadius?: number;
124
-
125
- /**
126
- * Optional background video (URL or require path)
127
- * Plays in loop behind content
128
- */
129
45
  backgroundVideo?: VideoSource;
130
-
131
- /**
132
- * Opacity of the overlay color on top of background media
133
- * Range: 0.0 to 1.0 (Default: 0.5)
134
- */
135
46
  overlayOpacity?: number;
136
-
137
- /**
138
- * Optional features list to display
139
- */
140
47
  features?: string[];
141
-
142
- /**
143
- * Optional question for personalization
144
- * Only used when type is "question"
145
- */
146
48
  question?: OnboardingQuestion;
147
-
148
- /**
149
- * Skip this slide if condition is met
150
- * @param answers - Previous answers
151
- * @returns true to skip, false to show
152
- */
153
49
  skipIf?: (answers: Record<string, OnboardingAnswerValue>) => boolean;
154
50
  }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Image Source Type
3
- * Domain type for image sources used in onboarding slides
3
+ * Domain type for image sources used in onboarding slides.
4
+ * Compatible with expo-image ImageSource (optional peer dep).
4
5
  */
5
6
 
6
- import type { ImageSource } from "expo-image";
7
-
8
- export type ImageSourceType = ImageSource | number | string;
7
+ export type ImageSourceType =
8
+ | number // require() static assets
9
+ | string // URI string
10
+ | { uri: string; headers?: Record<string, string>; cacheKey?: string; [key: string]: unknown };
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Background Image Collage Component
3
- * Displays multiple images in various layout patterns with safe area support
3
+ * Displays multiple images in various layout patterns with safe area support.
4
+ * Uses expo-image when available, falls back to React Native Image.
4
5
  */
5
6
 
6
7
  import React, { useMemo } from "react";
7
- import { View, StyleSheet } from "react-native";
8
- import { Image } from "expo-image";
8
+ import { View, Image as RNImage, StyleSheet } from "react-native";
9
9
  import { useSafeAreaInsets } from "../../../safe-area/hooks/useSafeAreaInsets";
10
10
  import {
11
11
  generateGridLayout,
@@ -20,6 +20,15 @@ import {
20
20
  type ImageSourceType,
21
21
  } from "../../infrastructure/utils/layouts";
22
22
 
23
+ // Lazy-load expo-image (optional peer dep)
24
+ let ExpoImage: React.ComponentType<any> | null = null;
25
+ try {
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ ExpoImage = require('expo-image').Image;
28
+ } catch {
29
+ // expo-image not installed — using React Native Image fallback
30
+ }
31
+
23
32
  export type CollageLayout =
24
33
  | "grid"
25
34
  | "dense"
@@ -62,28 +71,34 @@ export const BackgroundImageCollage: React.FC<BackgroundImageCollageProps> = ({
62
71
 
63
72
  const imageLayouts = useMemo(() => {
64
73
  if (!images || images.length === 0) return [];
65
-
66
74
  const generator = LAYOUT_GENERATORS[layout] ?? generateGridLayout;
67
- return generator(images, {
68
- columns,
69
- gap,
70
- borderRadius,
71
- safeAreaInsets: insets
72
- });
75
+ return generator(images, { columns, gap, borderRadius, safeAreaInsets: insets });
73
76
  }, [images, layout, columns, gap, borderRadius, insets]);
74
77
 
75
78
  if (imageLayouts.length === 0) return null;
76
79
 
77
80
  return (
78
81
  <View style={[StyleSheet.absoluteFill, { opacity }]} pointerEvents="none">
79
- {imageLayouts.map((item) => (
80
- <Image
81
- key={String(item.source)}
82
- source={item.source}
83
- style={item.style}
84
- contentFit="cover"
85
- />
86
- ))}
82
+ {imageLayouts.map((item) => {
83
+ if (ExpoImage) {
84
+ return (
85
+ <ExpoImage
86
+ key={String(item.source)}
87
+ source={item.source}
88
+ style={item.style}
89
+ contentFit="cover"
90
+ />
91
+ );
92
+ }
93
+ return (
94
+ <RNImage
95
+ key={String(item.source)}
96
+ source={item.source as any}
97
+ style={item.style as any}
98
+ resizeMode="cover"
99
+ />
100
+ );
101
+ })}
87
102
  </View>
88
103
  );
89
104
  };
@@ -1,25 +1,47 @@
1
1
  import React from 'react';
2
2
  import { StyleSheet, View } from 'react-native';
3
- import { useVideoPlayer, VideoView } from 'expo-video';
4
- import type { VideoSource, VideoPlayer } from 'expo-video';
3
+
4
+ // Compatible with expo-video VideoSource (optional peer dep)
5
+ type VideoSource = string | { uri: string; [key: string]: unknown };
5
6
 
6
7
  interface BackgroundVideoProps {
7
- source: VideoSource;
8
- overlayOpacity?: number;
8
+ source: VideoSource;
9
+ overlayOpacity?: number;
9
10
  }
10
11
 
11
- export const BackgroundVideo = ({ source, overlayOpacity = 0.5 }: BackgroundVideoProps) => {
12
- const player = useVideoPlayer(source, (p: VideoPlayer) => {
13
- p.loop = true;
14
- p.play();
15
- p.muted = true;
12
+ // Try to build the video component only when expo-video is available
13
+ let BackgroundVideoImpl: React.FC<BackgroundVideoProps> | null = null;
14
+
15
+ try {
16
+ const { useVideoPlayer, VideoView } = require('expo-video'); // eslint-disable-line @typescript-eslint/no-require-imports
17
+
18
+ BackgroundVideoImpl = ({ source, overlayOpacity = 0.5 }: BackgroundVideoProps) => {
19
+ // eslint-disable-next-line react-hooks/rules-of-hooks
20
+ const player = useVideoPlayer(source, (p: any) => {
21
+ p.loop = true;
22
+ p.play();
23
+ p.muted = true;
16
24
  });
17
25
 
18
26
  return (
19
- <View style={StyleSheet.absoluteFill}>
20
- <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />
21
- <View style={[StyleSheet.absoluteFill, { backgroundColor: `rgba(0,0,0,${overlayOpacity})` }]} />
22
- </View>
27
+ <View style={StyleSheet.absoluteFill}>
28
+ <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />
29
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: `rgba(0,0,0,${overlayOpacity})` }]} />
30
+ </View>
23
31
  );
24
- };
32
+ };
33
+ } catch {
34
+ // expo-video not installed — BackgroundVideoImpl stays null
35
+ }
25
36
 
37
+ export const BackgroundVideo: React.FC<BackgroundVideoProps> = (props) => {
38
+ if (BackgroundVideoImpl) {
39
+ return React.createElement(BackgroundVideoImpl, props);
40
+ }
41
+ // Fallback: dark overlay only
42
+ return (
43
+ <View
44
+ style={[StyleSheet.absoluteFill, { backgroundColor: `rgba(0,0,0,${props.overlayOpacity ?? 0.5})` }]}
45
+ />
46
+ );
47
+ };
@@ -1,46 +1,49 @@
1
1
  /**
2
2
  * UUID Generation Utility
3
3
  *
4
- * Provides cross-platform UUID generation using expo-crypto.
5
- * Compatible with React Native (iOS, Android) and Web.
4
+ * Provides cross-platform UUID generation.
5
+ * Uses expo-crypto when available, falls back to Math.random-based v4 UUID.
6
6
  */
7
7
 
8
- import * as Crypto from 'expo-crypto';
9
8
  import type { UUID } from '../../types/UUID';
10
9
  import { UUID_CONSTANTS } from '../../types/UUID';
11
10
 
11
+ // Lazy-load expo-crypto to avoid crash when native module is not available
12
+ let _cryptoModule: typeof import('expo-crypto') | null = null;
13
+
14
+ const getCryptoModule = (): typeof import('expo-crypto') | null => {
15
+ if (_cryptoModule !== null) return _cryptoModule;
16
+ try {
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ _cryptoModule = require('expo-crypto') as typeof import('expo-crypto');
19
+ return _cryptoModule;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const fallbackUUID = (): UUID => {
26
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
27
+ const r = (Math.random() * 16) | 0;
28
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
29
+ return v.toString(16);
30
+ }) as UUID;
31
+ };
32
+
12
33
  /**
13
34
  * Generate a v4 UUID
14
- * Uses expo-crypto's randomUUID() for secure UUID generation
15
- *
16
- * @returns A v4 UUID string
17
- *
18
- * @example
19
- * ```typescript
20
- * import { generateUUID } from '@umituz/react-native-uuid';
21
- *
22
- * const id = generateUUID();
23
- * // Returns: "550e8400-e29b-41d4-a716-446655440000"
24
- * ```
35
+ * Uses expo-crypto's randomUUID() when available, otherwise Math.random fallback
25
36
  */
26
37
  export const generateUUID = (): UUID => {
27
- return Crypto.randomUUID() as UUID;
38
+ const Crypto = getCryptoModule();
39
+ if (Crypto) {
40
+ return Crypto.randomUUID() as UUID;
41
+ }
42
+ return fallbackUUID();
28
43
  };
29
44
 
30
45
  /**
31
46
  * Validate UUID format
32
- * Checks if a string is a valid v4 UUID
33
- *
34
- * @param value - The value to validate
35
- * @returns True if the value is a valid v4 UUID
36
- *
37
- * @example
38
- * ```typescript
39
- * import { isValidUUID } from '@umituz/react-native-uuid';
40
- *
41
- * isValidUUID('550e8400-e29b-41d4-a716-446655440000'); // true
42
- * isValidUUID('invalid-uuid'); // false
43
- * ```
44
47
  */
45
48
  export const isValidUUID = (value: string): value is UUID => {
46
49
  return UUID_CONSTANTS.PATTERN.test(value);
@@ -48,19 +51,6 @@ export const isValidUUID = (value: string): value is UUID => {
48
51
 
49
52
  /**
50
53
  * Get version from UUID string
51
- * Returns the UUID version number (1-5) or null for NIL/invalid
52
- *
53
- * @param value - The UUID string
54
- * @returns UUID version number or null
55
- *
56
- * @example
57
- * ```typescript
58
- * import { getUUIDVersion } from '@umituz/react-native-uuid';
59
- *
60
- * getUUIDVersion('550e8400-e29b-41d4-a716-446655440000'); // 4
61
- * getUUIDVersion('00000000-0000-0000-0000-000000000000'); // 0 (NIL)
62
- * getUUIDVersion('invalid'); // null
63
- * ```
64
54
  */
65
55
  export const getUUIDVersion = (value: string): number | null => {
66
56
  if (value === UUID_CONSTANTS.NIL) {
@@ -72,4 +62,3 @@ export const getUUIDVersion = (value: string): number | null => {
72
62
 
73
63
  return (version >= 1 && version <= 5) ? version : null;
74
64
  };
75
-