@croacroa/react-native-template 2.1.0 → 3.2.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.
Files changed (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -21
  10. package/README.md +446 -402
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -375
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -176
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
@@ -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
+ });