@croacroa/react-native-template 1.0.0 → 2.0.0
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/.github/workflows/ci.yml +187 -184
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/CHANGELOG.md +106 -106
- package/CONTRIBUTING.md +377 -377
- package/README.md +399 -399
- package/__tests__/components/snapshots.test.tsx +131 -0
- package/__tests__/integration/auth-api.test.tsx +227 -0
- package/__tests__/performance/VirtualizedList.perf.test.tsx +362 -0
- package/app/(public)/onboarding.tsx +5 -5
- package/app.config.ts +45 -2
- package/assets/images/.gitkeep +7 -7
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/SuspenseBoundary.tsx +357 -0
- package/components/providers/index.ts +13 -0
- package/components/ui/Avatar.tsx +316 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Select.tsx +240 -240
- package/components/ui/VirtualizedList.tsx +285 -0
- package/components/ui/index.ts +23 -18
- package/constants/config.ts +97 -54
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/hooks/index.ts +27 -25
- package/hooks/useApi.ts +102 -5
- package/hooks/useAuth.tsx +82 -0
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useMFA.ts +499 -0
- package/hooks/useNotifications.ts +39 -0
- package/hooks/useOffline.ts +32 -2
- package/hooks/usePerformance.ts +434 -434
- package/hooks/useTheme.tsx +76 -0
- package/hooks/useUpdates.ts +358 -358
- package/i18n/index.ts +194 -77
- package/i18n/locales/ar.json +101 -0
- package/i18n/locales/de.json +101 -0
- package/i18n/locales/en.json +101 -101
- package/i18n/locales/es.json +101 -0
- package/i18n/locales/fr.json +101 -101
- package/jest.config.js +4 -4
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -0
- package/maestro/flows/mfa-setup.yaml +86 -0
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -0
- package/maestro/flows/offline-sync.yaml +128 -0
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +175 -170
- package/services/analytics.ts +428 -428
- package/services/api.ts +340 -340
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +626 -0
- package/services/index.ts +54 -22
- package/services/security.ts +229 -0
- package/tailwind.config.js +47 -47
- package/utils/accessibility.ts +446 -446
- package/utils/index.ts +52 -43
- package/utils/withAccessibility.tsx +272 -0
|
@@ -1,369 +1,369 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
3
|
-
import { Image, ImageProps, ImageContentFit } from "expo-image";
|
|
4
|
-
import Animated, {
|
|
5
|
-
useAnimatedStyle,
|
|
6
|
-
withTiming,
|
|
7
|
-
interpolate,
|
|
8
|
-
useSharedValue,
|
|
9
|
-
} from "react-native-reanimated";
|
|
10
|
-
import { useTheme } from "@/hooks/useTheme";
|
|
11
|
-
import { cn } from "@/utils/cn";
|
|
12
|
-
|
|
13
|
-
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
14
|
-
|
|
15
|
-
type ImagePriority = "low" | "normal" | "high";
|
|
16
|
-
|
|
17
|
-
interface OptimizedImageProps {
|
|
18
|
-
/**
|
|
19
|
-
* Image source URL
|
|
20
|
-
*/
|
|
21
|
-
source: string | number;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Alt text for accessibility
|
|
25
|
-
*/
|
|
26
|
-
alt?: string;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Image width
|
|
30
|
-
*/
|
|
31
|
-
width?: number | string;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Image height
|
|
35
|
-
*/
|
|
36
|
-
height?: number | string;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Aspect ratio (e.g., 16/9, 1, 4/3)
|
|
40
|
-
*/
|
|
41
|
-
aspectRatio?: number;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* How to fit the image in the container
|
|
45
|
-
*/
|
|
46
|
-
contentFit?: ImageContentFit;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Blur hash or thumbhash for placeholder
|
|
50
|
-
*/
|
|
51
|
-
placeholder?: string | number;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Loading priority
|
|
55
|
-
*/
|
|
56
|
-
priority?: ImagePriority;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Whether to enable caching
|
|
60
|
-
*/
|
|
61
|
-
cachePolicy?: "none" | "disk" | "memory" | "memory-disk";
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Transition duration in ms
|
|
65
|
-
*/
|
|
66
|
-
transitionDuration?: number;
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Additional class name for container
|
|
70
|
-
*/
|
|
71
|
-
className?: string;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Whether to show loading skeleton
|
|
75
|
-
*/
|
|
76
|
-
showSkeleton?: boolean;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Border radius
|
|
80
|
-
*/
|
|
81
|
-
borderRadius?: number;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Called when image loads successfully
|
|
85
|
-
*/
|
|
86
|
-
onLoad?: () => void;
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Called when image fails to load
|
|
90
|
-
*/
|
|
91
|
-
onError?: (error: Error) => void;
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Style override
|
|
95
|
-
*/
|
|
96
|
-
style?: ImageProps["style"];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function OptimizedImage({
|
|
100
|
-
source,
|
|
101
|
-
alt,
|
|
102
|
-
width,
|
|
103
|
-
height,
|
|
104
|
-
aspectRatio,
|
|
105
|
-
contentFit = "cover",
|
|
106
|
-
placeholder,
|
|
107
|
-
priority = "normal",
|
|
108
|
-
cachePolicy = "memory-disk",
|
|
109
|
-
transitionDuration = 300,
|
|
110
|
-
className,
|
|
111
|
-
showSkeleton = true,
|
|
112
|
-
borderRadius = 0,
|
|
113
|
-
onLoad,
|
|
114
|
-
onError,
|
|
115
|
-
style,
|
|
116
|
-
}: OptimizedImageProps) {
|
|
117
|
-
const { isDark } = useTheme();
|
|
118
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
119
|
-
const [hasError, setHasError] = useState(false);
|
|
120
|
-
const opacity = useSharedValue(0);
|
|
121
|
-
|
|
122
|
-
const handleLoad = useCallback(() => {
|
|
123
|
-
setIsLoading(false);
|
|
124
|
-
opacity.value = withTiming(1, { duration: transitionDuration });
|
|
125
|
-
onLoad?.();
|
|
126
|
-
}, [opacity, transitionDuration, onLoad]);
|
|
127
|
-
|
|
128
|
-
const handleError = useCallback(
|
|
129
|
-
(error: { error: string }) => {
|
|
130
|
-
setIsLoading(false);
|
|
131
|
-
setHasError(true);
|
|
132
|
-
onError?.(new Error(error.error));
|
|
133
|
-
},
|
|
134
|
-
[onError]
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
const animatedStyle = useAnimatedStyle(() => {
|
|
138
|
-
return {
|
|
139
|
-
opacity: interpolate(opacity.value, [0, 1], [0, 1]),
|
|
140
|
-
};
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// Determine the source
|
|
144
|
-
const imageSource = typeof source === "string" ? { uri: source } : source;
|
|
145
|
-
|
|
146
|
-
// Map priority to expo-image priority
|
|
147
|
-
const imagePriority =
|
|
148
|
-
priority === "high" ? "high" : priority === "low" ? "low" : "normal";
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<View
|
|
152
|
-
className={cn("overflow-hidden", className)}
|
|
153
|
-
style={[
|
|
154
|
-
{
|
|
155
|
-
width,
|
|
156
|
-
height,
|
|
157
|
-
aspectRatio,
|
|
158
|
-
borderRadius,
|
|
159
|
-
backgroundColor: isDark ? "#1e293b" : "#f1f5f9",
|
|
160
|
-
},
|
|
161
|
-
]}
|
|
162
|
-
>
|
|
163
|
-
{/* Skeleton loader */}
|
|
164
|
-
{showSkeleton && isLoading && !hasError && (
|
|
165
|
-
<SkeletonLoader borderRadius={borderRadius} />
|
|
166
|
-
)}
|
|
167
|
-
|
|
168
|
-
{/* Error state */}
|
|
169
|
-
{hasError && (
|
|
170
|
-
<View
|
|
171
|
-
style={[styles.errorContainer, { borderRadius }]}
|
|
172
|
-
className={isDark ? "bg-gray-800" : "bg-gray-100"}
|
|
173
|
-
>
|
|
174
|
-
<View className="items-center justify-center">
|
|
175
|
-
<View
|
|
176
|
-
className={cn(
|
|
177
|
-
"w-12 h-12 rounded-full items-center justify-center mb-2",
|
|
178
|
-
isDark ? "bg-gray-700" : "bg-gray-200"
|
|
179
|
-
)}
|
|
180
|
-
>
|
|
181
|
-
<ErrorIcon isDark={isDark} />
|
|
182
|
-
</View>
|
|
183
|
-
</View>
|
|
184
|
-
</View>
|
|
185
|
-
)}
|
|
186
|
-
|
|
187
|
-
{/* Actual image */}
|
|
188
|
-
{!hasError && (
|
|
189
|
-
<AnimatedImage
|
|
190
|
-
source={imageSource}
|
|
191
|
-
contentFit={contentFit}
|
|
192
|
-
placeholder={placeholder}
|
|
193
|
-
placeholderContentFit="cover"
|
|
194
|
-
transition={transitionDuration}
|
|
195
|
-
priority={imagePriority}
|
|
196
|
-
cachePolicy={cachePolicy}
|
|
197
|
-
onLoad={handleLoad}
|
|
198
|
-
onError={handleError}
|
|
199
|
-
accessibilityLabel={alt}
|
|
200
|
-
style={[styles.image, { borderRadius }, animatedStyle, style]}
|
|
201
|
-
/>
|
|
202
|
-
)}
|
|
203
|
-
</View>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Skeleton loader with shimmer effect
|
|
209
|
-
*/
|
|
210
|
-
function SkeletonLoader({ borderRadius }: { borderRadius: number }) {
|
|
211
|
-
const { isDark } = useTheme();
|
|
212
|
-
const shimmer = useSharedValue(0);
|
|
213
|
-
|
|
214
|
-
// Start shimmer animation
|
|
215
|
-
useState(() => {
|
|
216
|
-
shimmer.value = withTiming(1, { duration: 1500 }, () => {
|
|
217
|
-
shimmer.value = 0;
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const shimmerStyle = useAnimatedStyle(() => {
|
|
222
|
-
return {
|
|
223
|
-
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
|
|
224
|
-
};
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
return (
|
|
228
|
-
<Animated.View
|
|
229
|
-
style={[
|
|
230
|
-
styles.skeleton,
|
|
231
|
-
{ borderRadius },
|
|
232
|
-
shimmerStyle,
|
|
233
|
-
{ backgroundColor: isDark ? "#334155" : "#e2e8f0" },
|
|
234
|
-
]}
|
|
235
|
-
/>
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Error icon component
|
|
241
|
-
*/
|
|
242
|
-
function ErrorIcon({ isDark }: { isDark: boolean }) {
|
|
243
|
-
return (
|
|
244
|
-
<View
|
|
245
|
-
style={{
|
|
246
|
-
width: 24,
|
|
247
|
-
height: 24,
|
|
248
|
-
borderRadius: 12,
|
|
249
|
-
backgroundColor: isDark ? "#475569" : "#cbd5e1",
|
|
250
|
-
alignItems: "center",
|
|
251
|
-
justifyContent: "center",
|
|
252
|
-
}}
|
|
253
|
-
>
|
|
254
|
-
<View
|
|
255
|
-
style={{
|
|
256
|
-
width: 12,
|
|
257
|
-
height: 2,
|
|
258
|
-
backgroundColor: isDark ? "#94a3b8" : "#64748b",
|
|
259
|
-
transform: [{ rotate: "45deg" }],
|
|
260
|
-
}}
|
|
261
|
-
/>
|
|
262
|
-
</View>
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const styles = StyleSheet.create({
|
|
267
|
-
image: {
|
|
268
|
-
width: "100%",
|
|
269
|
-
height: "100%",
|
|
270
|
-
position: "absolute",
|
|
271
|
-
top: 0,
|
|
272
|
-
left: 0,
|
|
273
|
-
},
|
|
274
|
-
skeleton: {
|
|
275
|
-
...StyleSheet.absoluteFillObject,
|
|
276
|
-
},
|
|
277
|
-
errorContainer: {
|
|
278
|
-
...StyleSheet.absoluteFillObject,
|
|
279
|
-
alignItems: "center",
|
|
280
|
-
justifyContent: "center",
|
|
281
|
-
},
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Background Image component
|
|
286
|
-
*/
|
|
287
|
-
interface BackgroundImageProps extends OptimizedImageProps {
|
|
288
|
-
children?: React.ReactNode;
|
|
289
|
-
overlayColor?: string;
|
|
290
|
-
overlayOpacity?: number;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function BackgroundImage({
|
|
294
|
-
children,
|
|
295
|
-
overlayColor = "#000000",
|
|
296
|
-
overlayOpacity = 0.4,
|
|
297
|
-
...props
|
|
298
|
-
}: BackgroundImageProps) {
|
|
299
|
-
return (
|
|
300
|
-
<View style={styles.backgroundContainer}>
|
|
301
|
-
<OptimizedImage {...props} style={StyleSheet.absoluteFill} />
|
|
302
|
-
{overlayOpacity > 0 && (
|
|
303
|
-
<View
|
|
304
|
-
style={[
|
|
305
|
-
StyleSheet.absoluteFill,
|
|
306
|
-
{
|
|
307
|
-
backgroundColor: overlayColor,
|
|
308
|
-
opacity: overlayOpacity,
|
|
309
|
-
},
|
|
310
|
-
]}
|
|
311
|
-
/>
|
|
312
|
-
)}
|
|
313
|
-
<View style={styles.backgroundContent}>{children}</View>
|
|
314
|
-
</View>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Image with progressive loading (blur to sharp)
|
|
320
|
-
*/
|
|
321
|
-
interface ProgressiveImageProps extends OptimizedImageProps {
|
|
322
|
-
/**
|
|
323
|
-
* Low-quality placeholder image
|
|
324
|
-
*/
|
|
325
|
-
thumbnail?: string;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
export function ProgressiveImage({
|
|
329
|
-
thumbnail,
|
|
330
|
-
source,
|
|
331
|
-
...props
|
|
332
|
-
}: ProgressiveImageProps) {
|
|
333
|
-
const [isFullLoaded, setIsFullLoaded] = useState(false);
|
|
334
|
-
|
|
335
|
-
return (
|
|
336
|
-
<View style={{ position: "relative", overflow: "hidden" }}>
|
|
337
|
-
{/* Thumbnail (blurred) */}
|
|
338
|
-
{thumbnail && !isFullLoaded && (
|
|
339
|
-
<Image
|
|
340
|
-
source={{ uri: thumbnail }}
|
|
341
|
-
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
|
|
342
|
-
contentFit="cover"
|
|
343
|
-
blurRadius={10}
|
|
344
|
-
/>
|
|
345
|
-
)}
|
|
346
|
-
|
|
347
|
-
{/* Full resolution image */}
|
|
348
|
-
<OptimizedImage
|
|
349
|
-
{...props}
|
|
350
|
-
source={source}
|
|
351
|
-
onLoad={() => {
|
|
352
|
-
setIsFullLoaded(true);
|
|
353
|
-
props.onLoad?.();
|
|
354
|
-
}}
|
|
355
|
-
showSkeleton={!thumbnail}
|
|
356
|
-
/>
|
|
357
|
-
</View>
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
Object.assign(styles, {
|
|
362
|
-
backgroundContainer: {
|
|
363
|
-
flex: 1,
|
|
364
|
-
},
|
|
365
|
-
backgroundContent: {
|
|
366
|
-
flex: 1,
|
|
367
|
-
zIndex: 1,
|
|
368
|
-
},
|
|
369
|
-
});
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Image, ImageProps, ImageContentFit } from "expo-image";
|
|
4
|
+
import Animated, {
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withTiming,
|
|
7
|
+
interpolate,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
} from "react-native-reanimated";
|
|
10
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
11
|
+
import { cn } from "@/utils/cn";
|
|
12
|
+
|
|
13
|
+
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
14
|
+
|
|
15
|
+
type ImagePriority = "low" | "normal" | "high";
|
|
16
|
+
|
|
17
|
+
interface OptimizedImageProps {
|
|
18
|
+
/**
|
|
19
|
+
* Image source URL
|
|
20
|
+
*/
|
|
21
|
+
source: string | number;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Alt text for accessibility
|
|
25
|
+
*/
|
|
26
|
+
alt?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Image width
|
|
30
|
+
*/
|
|
31
|
+
width?: number | string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Image height
|
|
35
|
+
*/
|
|
36
|
+
height?: number | string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Aspect ratio (e.g., 16/9, 1, 4/3)
|
|
40
|
+
*/
|
|
41
|
+
aspectRatio?: number;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How to fit the image in the container
|
|
45
|
+
*/
|
|
46
|
+
contentFit?: ImageContentFit;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Blur hash or thumbhash for placeholder
|
|
50
|
+
*/
|
|
51
|
+
placeholder?: string | number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loading priority
|
|
55
|
+
*/
|
|
56
|
+
priority?: ImagePriority;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether to enable caching
|
|
60
|
+
*/
|
|
61
|
+
cachePolicy?: "none" | "disk" | "memory" | "memory-disk";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Transition duration in ms
|
|
65
|
+
*/
|
|
66
|
+
transitionDuration?: number;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Additional class name for container
|
|
70
|
+
*/
|
|
71
|
+
className?: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether to show loading skeleton
|
|
75
|
+
*/
|
|
76
|
+
showSkeleton?: boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Border radius
|
|
80
|
+
*/
|
|
81
|
+
borderRadius?: number;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Called when image loads successfully
|
|
85
|
+
*/
|
|
86
|
+
onLoad?: () => void;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Called when image fails to load
|
|
90
|
+
*/
|
|
91
|
+
onError?: (error: Error) => void;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Style override
|
|
95
|
+
*/
|
|
96
|
+
style?: ImageProps["style"];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function OptimizedImage({
|
|
100
|
+
source,
|
|
101
|
+
alt,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
aspectRatio,
|
|
105
|
+
contentFit = "cover",
|
|
106
|
+
placeholder,
|
|
107
|
+
priority = "normal",
|
|
108
|
+
cachePolicy = "memory-disk",
|
|
109
|
+
transitionDuration = 300,
|
|
110
|
+
className,
|
|
111
|
+
showSkeleton = true,
|
|
112
|
+
borderRadius = 0,
|
|
113
|
+
onLoad,
|
|
114
|
+
onError,
|
|
115
|
+
style,
|
|
116
|
+
}: OptimizedImageProps) {
|
|
117
|
+
const { isDark } = useTheme();
|
|
118
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
119
|
+
const [hasError, setHasError] = useState(false);
|
|
120
|
+
const opacity = useSharedValue(0);
|
|
121
|
+
|
|
122
|
+
const handleLoad = useCallback(() => {
|
|
123
|
+
setIsLoading(false);
|
|
124
|
+
opacity.value = withTiming(1, { duration: transitionDuration });
|
|
125
|
+
onLoad?.();
|
|
126
|
+
}, [opacity, transitionDuration, onLoad]);
|
|
127
|
+
|
|
128
|
+
const handleError = useCallback(
|
|
129
|
+
(error: { error: string }) => {
|
|
130
|
+
setIsLoading(false);
|
|
131
|
+
setHasError(true);
|
|
132
|
+
onError?.(new Error(error.error));
|
|
133
|
+
},
|
|
134
|
+
[onError]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
138
|
+
return {
|
|
139
|
+
opacity: interpolate(opacity.value, [0, 1], [0, 1]),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Determine the source
|
|
144
|
+
const imageSource = typeof source === "string" ? { uri: source } : source;
|
|
145
|
+
|
|
146
|
+
// Map priority to expo-image priority
|
|
147
|
+
const imagePriority =
|
|
148
|
+
priority === "high" ? "high" : priority === "low" ? "low" : "normal";
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<View
|
|
152
|
+
className={cn("overflow-hidden", className)}
|
|
153
|
+
style={[
|
|
154
|
+
{
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
aspectRatio,
|
|
158
|
+
borderRadius,
|
|
159
|
+
backgroundColor: isDark ? "#1e293b" : "#f1f5f9",
|
|
160
|
+
},
|
|
161
|
+
]}
|
|
162
|
+
>
|
|
163
|
+
{/* Skeleton loader */}
|
|
164
|
+
{showSkeleton && isLoading && !hasError && (
|
|
165
|
+
<SkeletonLoader borderRadius={borderRadius} />
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Error state */}
|
|
169
|
+
{hasError && (
|
|
170
|
+
<View
|
|
171
|
+
style={[styles.errorContainer, { borderRadius }]}
|
|
172
|
+
className={isDark ? "bg-gray-800" : "bg-gray-100"}
|
|
173
|
+
>
|
|
174
|
+
<View className="items-center justify-center">
|
|
175
|
+
<View
|
|
176
|
+
className={cn(
|
|
177
|
+
"w-12 h-12 rounded-full items-center justify-center mb-2",
|
|
178
|
+
isDark ? "bg-gray-700" : "bg-gray-200"
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
<ErrorIcon isDark={isDark} />
|
|
182
|
+
</View>
|
|
183
|
+
</View>
|
|
184
|
+
</View>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Actual image */}
|
|
188
|
+
{!hasError && (
|
|
189
|
+
<AnimatedImage
|
|
190
|
+
source={imageSource}
|
|
191
|
+
contentFit={contentFit}
|
|
192
|
+
placeholder={placeholder}
|
|
193
|
+
placeholderContentFit="cover"
|
|
194
|
+
transition={transitionDuration}
|
|
195
|
+
priority={imagePriority}
|
|
196
|
+
cachePolicy={cachePolicy}
|
|
197
|
+
onLoad={handleLoad}
|
|
198
|
+
onError={handleError}
|
|
199
|
+
accessibilityLabel={alt}
|
|
200
|
+
style={[styles.image, { borderRadius }, animatedStyle, style]}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</View>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Skeleton loader with shimmer effect
|
|
209
|
+
*/
|
|
210
|
+
function SkeletonLoader({ borderRadius }: { borderRadius: number }) {
|
|
211
|
+
const { isDark } = useTheme();
|
|
212
|
+
const shimmer = useSharedValue(0);
|
|
213
|
+
|
|
214
|
+
// Start shimmer animation
|
|
215
|
+
useState(() => {
|
|
216
|
+
shimmer.value = withTiming(1, { duration: 1500 }, () => {
|
|
217
|
+
shimmer.value = 0;
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const shimmerStyle = useAnimatedStyle(() => {
|
|
222
|
+
return {
|
|
223
|
+
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Animated.View
|
|
229
|
+
style={[
|
|
230
|
+
styles.skeleton,
|
|
231
|
+
{ borderRadius },
|
|
232
|
+
shimmerStyle,
|
|
233
|
+
{ backgroundColor: isDark ? "#334155" : "#e2e8f0" },
|
|
234
|
+
]}
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Error icon component
|
|
241
|
+
*/
|
|
242
|
+
function ErrorIcon({ isDark }: { isDark: boolean }) {
|
|
243
|
+
return (
|
|
244
|
+
<View
|
|
245
|
+
style={{
|
|
246
|
+
width: 24,
|
|
247
|
+
height: 24,
|
|
248
|
+
borderRadius: 12,
|
|
249
|
+
backgroundColor: isDark ? "#475569" : "#cbd5e1",
|
|
250
|
+
alignItems: "center",
|
|
251
|
+
justifyContent: "center",
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<View
|
|
255
|
+
style={{
|
|
256
|
+
width: 12,
|
|
257
|
+
height: 2,
|
|
258
|
+
backgroundColor: isDark ? "#94a3b8" : "#64748b",
|
|
259
|
+
transform: [{ rotate: "45deg" }],
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
</View>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const styles = StyleSheet.create({
|
|
267
|
+
image: {
|
|
268
|
+
width: "100%",
|
|
269
|
+
height: "100%",
|
|
270
|
+
position: "absolute",
|
|
271
|
+
top: 0,
|
|
272
|
+
left: 0,
|
|
273
|
+
},
|
|
274
|
+
skeleton: {
|
|
275
|
+
...StyleSheet.absoluteFillObject,
|
|
276
|
+
},
|
|
277
|
+
errorContainer: {
|
|
278
|
+
...StyleSheet.absoluteFillObject,
|
|
279
|
+
alignItems: "center",
|
|
280
|
+
justifyContent: "center",
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Background Image component
|
|
286
|
+
*/
|
|
287
|
+
interface BackgroundImageProps extends OptimizedImageProps {
|
|
288
|
+
children?: React.ReactNode;
|
|
289
|
+
overlayColor?: string;
|
|
290
|
+
overlayOpacity?: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function BackgroundImage({
|
|
294
|
+
children,
|
|
295
|
+
overlayColor = "#000000",
|
|
296
|
+
overlayOpacity = 0.4,
|
|
297
|
+
...props
|
|
298
|
+
}: BackgroundImageProps) {
|
|
299
|
+
return (
|
|
300
|
+
<View style={styles.backgroundContainer}>
|
|
301
|
+
<OptimizedImage {...props} style={StyleSheet.absoluteFill} />
|
|
302
|
+
{overlayOpacity > 0 && (
|
|
303
|
+
<View
|
|
304
|
+
style={[
|
|
305
|
+
StyleSheet.absoluteFill,
|
|
306
|
+
{
|
|
307
|
+
backgroundColor: overlayColor,
|
|
308
|
+
opacity: overlayOpacity,
|
|
309
|
+
},
|
|
310
|
+
]}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
<View style={styles.backgroundContent}>{children}</View>
|
|
314
|
+
</View>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Image with progressive loading (blur to sharp)
|
|
320
|
+
*/
|
|
321
|
+
interface ProgressiveImageProps extends OptimizedImageProps {
|
|
322
|
+
/**
|
|
323
|
+
* Low-quality placeholder image
|
|
324
|
+
*/
|
|
325
|
+
thumbnail?: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function ProgressiveImage({
|
|
329
|
+
thumbnail,
|
|
330
|
+
source,
|
|
331
|
+
...props
|
|
332
|
+
}: ProgressiveImageProps) {
|
|
333
|
+
const [isFullLoaded, setIsFullLoaded] = useState(false);
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<View style={{ position: "relative", overflow: "hidden" }}>
|
|
337
|
+
{/* Thumbnail (blurred) */}
|
|
338
|
+
{thumbnail && !isFullLoaded && (
|
|
339
|
+
<Image
|
|
340
|
+
source={{ uri: thumbnail }}
|
|
341
|
+
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
|
|
342
|
+
contentFit="cover"
|
|
343
|
+
blurRadius={10}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* Full resolution image */}
|
|
348
|
+
<OptimizedImage
|
|
349
|
+
{...props}
|
|
350
|
+
source={source}
|
|
351
|
+
onLoad={() => {
|
|
352
|
+
setIsFullLoaded(true);
|
|
353
|
+
props.onLoad?.();
|
|
354
|
+
}}
|
|
355
|
+
showSkeleton={!thumbnail}
|
|
356
|
+
/>
|
|
357
|
+
</View>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
Object.assign(styles, {
|
|
362
|
+
backgroundContainer: {
|
|
363
|
+
flex: 1,
|
|
364
|
+
},
|
|
365
|
+
backgroundContent: {
|
|
366
|
+
flex: 1,
|
|
367
|
+
zIndex: 1,
|
|
368
|
+
},
|
|
369
|
+
});
|