@umituz/react-native-design-system 4.25.39 → 4.25.41
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 +34 -1
- package/src/atoms/image/AtomicImage.tsx +41 -8
- package/src/device/detection/deviceDetection.ts +26 -52
- package/src/device/infrastructure/services/DeviceInfoService.ts +33 -32
- package/src/haptics/infrastructure/services/HapticService.ts +30 -59
- package/src/image/presentation/components/ImageGallery.tsx +121 -115
- package/src/onboarding/domain/entities/OnboardingSlide.ts +3 -107
- package/src/onboarding/domain/types/ImageSourceType.ts +6 -4
- package/src/onboarding/presentation/components/BackgroundImageCollage.tsx +33 -18
- package/src/onboarding/presentation/components/BackgroundVideo.tsx +36 -14
- package/src/uuid/infrastructure/utils/UUIDUtils.ts +30 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "4.25.
|
|
3
|
+
"version": "4.25.41",
|
|
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
|
|
2
|
+
import { Image as RNImage, type StyleProp, type ImageStyle } from 'react-native';
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
<
|
|
16
|
-
style={[
|
|
17
|
-
|
|
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
|
|
5
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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.OS === 'ios' && (Platform as any).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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
static async
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
static async
|
|
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
|
|
5
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
images,
|
|
35
|
+
visible,
|
|
36
|
+
onDismiss,
|
|
37
|
+
index = 0,
|
|
38
|
+
backgroundColor = '#000000',
|
|
39
|
+
onIndexChange,
|
|
40
|
+
onImageChange,
|
|
41
|
+
enableEditing = false,
|
|
42
|
+
title,
|
|
35
43
|
}) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
8
|
+
source: VideoSource;
|
|
9
|
+
overlayOpacity?: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
5
|
-
*
|
|
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()
|
|
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
|
-
|
|
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
|
-
|