@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,375 +1,281 @@
1
- /**
2
- * @fileoverview Image picker hook with permissions handling
3
- * Provides a simple interface for picking images from library or camera.
4
- * @module hooks/useImagePicker
5
- */
6
-
7
- import { useState, useCallback } from "react";
8
- import * as ImagePicker from "expo-image-picker";
9
- import { Alert, Platform } from "react-native";
10
-
11
- /**
12
- * Image picker options
13
- */
14
- export interface ImagePickerOptions {
15
- /** Allow editing/cropping the image */
16
- allowsEditing?: boolean;
17
- /** Aspect ratio for cropping [width, height] */
18
- aspect?: [number, number];
19
- /** Image quality (0-1) */
20
- quality?: number;
21
- /** Media types to allow */
22
- mediaTypes?: ImagePicker.MediaTypeOptions;
23
- /** Allow multiple selection (library only) */
24
- allowsMultipleSelection?: boolean;
25
- /** Maximum number of images to select */
26
- selectionLimit?: number;
27
- /** Base64 encode the image */
28
- base64?: boolean;
29
- /** Include EXIF data */
30
- exif?: boolean;
31
- }
32
-
33
- /**
34
- * Selected image result
35
- */
36
- export interface SelectedImage {
37
- uri: string;
38
- width: number;
39
- height: number;
40
- type?: string;
41
- fileName?: string;
42
- fileSize?: number;
43
- base64?: string;
44
- exif?: Record<string, unknown>;
45
- }
46
-
47
- /**
48
- * Hook return type
49
- */
50
- export interface UseImagePickerReturn {
51
- /** Currently selected image(s) */
52
- images: SelectedImage[];
53
- /** Whether an operation is in progress */
54
- isLoading: boolean;
55
- /** Last error that occurred */
56
- error: string | null;
57
- /** Pick image from library */
58
- pickFromLibrary: (options?: ImagePickerOptions) => Promise<SelectedImage[] | null>;
59
- /** Take photo with camera */
60
- takePhoto: (options?: ImagePickerOptions) => Promise<SelectedImage | null>;
61
- /** Show action sheet to choose source */
62
- pickImage: (options?: ImagePickerOptions) => Promise<SelectedImage[] | null>;
63
- /** Clear selected images */
64
- clear: () => void;
65
- /** Remove specific image by index */
66
- removeImage: (index: number) => void;
67
- }
68
-
69
- const DEFAULT_OPTIONS: ImagePickerOptions = {
70
- allowsEditing: true,
71
- aspect: [1, 1],
72
- quality: 0.8,
73
- mediaTypes: ImagePicker.MediaTypeOptions.Images,
74
- allowsMultipleSelection: false,
75
- base64: false,
76
- exif: false,
77
- };
78
-
79
- /**
80
- * Convert ImagePicker asset to SelectedImage
81
- */
82
- function assetToSelectedImage(asset: ImagePicker.ImagePickerAsset): SelectedImage {
83
- return {
84
- uri: asset.uri,
85
- width: asset.width,
86
- height: asset.height,
87
- type: asset.mimeType,
88
- fileName: asset.fileName ?? undefined,
89
- fileSize: asset.fileSize ?? undefined,
90
- base64: asset.base64 ?? undefined,
91
- exif: asset.exif ?? undefined,
92
- };
93
- }
94
-
95
- /**
96
- * Hook for picking images from library or camera.
97
- * Handles permissions automatically and provides a clean API.
98
- *
99
- * @example
100
- * ```tsx
101
- * function AvatarPicker() {
102
- * const { images, pickImage, isLoading } = useImagePicker();
103
- *
104
- * return (
105
- * <Pressable onPress={() => pickImage({ aspect: [1, 1] })}>
106
- * {images[0] ? (
107
- * <Image source={{ uri: images[0].uri }} style={styles.avatar} />
108
- * ) : (
109
- * <Text>Select Avatar</Text>
110
- * )}
111
- * </Pressable>
112
- * );
113
- * }
114
- * ```
115
- *
116
- * @example
117
- * ```tsx
118
- * // Multiple image selection
119
- * function GalleryPicker() {
120
- * const { images, pickFromLibrary, removeImage } = useImagePicker();
121
- *
122
- * const handlePick = () => {
123
- * pickFromLibrary({
124
- * allowsMultipleSelection: true,
125
- * selectionLimit: 5,
126
- * });
127
- * };
128
- *
129
- * return (
130
- * <View>
131
- * <Button onPress={handlePick}>Add Photos</Button>
132
- * {images.map((img, i) => (
133
- * <ImageThumb key={i} uri={img.uri} onRemove={() => removeImage(i)} />
134
- * ))}
135
- * </View>
136
- * );
137
- * }
138
- * ```
139
- */
140
- export function useImagePicker(): UseImagePickerReturn {
141
- const [images, setImages] = useState<SelectedImage[]>([]);
142
- const [isLoading, setIsLoading] = useState(false);
143
- const [error, setError] = useState<string | null>(null);
144
-
145
- /**
146
- * Request camera permissions
147
- */
148
- const requestCameraPermission = useCallback(async (): Promise<boolean> => {
149
- const { status } = await ImagePicker.requestCameraPermissionsAsync();
150
- if (status !== "granted") {
151
- Alert.alert(
152
- "Camera Permission Required",
153
- "Please allow camera access in your device settings to take photos.",
154
- [{ text: "OK" }]
155
- );
156
- return false;
157
- }
158
- return true;
159
- }, []);
160
-
161
- /**
162
- * Request media library permissions
163
- */
164
- const requestLibraryPermission = useCallback(async (): Promise<boolean> => {
165
- const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
166
- if (status !== "granted") {
167
- Alert.alert(
168
- "Photo Library Permission Required",
169
- "Please allow photo library access in your device settings to select images.",
170
- [{ text: "OK" }]
171
- );
172
- return false;
173
- }
174
- return true;
175
- }, []);
176
-
177
- /**
178
- * Pick image from library
179
- */
180
- const pickFromLibrary = useCallback(
181
- async (options: ImagePickerOptions = {}): Promise<SelectedImage[] | null> => {
182
- setError(null);
183
- setIsLoading(true);
184
-
185
- try {
186
- const hasPermission = await requestLibraryPermission();
187
- if (!hasPermission) {
188
- return null;
189
- }
190
-
191
- const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
192
-
193
- const result = await ImagePicker.launchImageLibraryAsync({
194
- mediaTypes: mergedOptions.mediaTypes,
195
- allowsEditing: mergedOptions.allowsMultipleSelection
196
- ? false
197
- : mergedOptions.allowsEditing,
198
- aspect: mergedOptions.aspect,
199
- quality: mergedOptions.quality,
200
- allowsMultipleSelection: mergedOptions.allowsMultipleSelection,
201
- selectionLimit: mergedOptions.selectionLimit,
202
- base64: mergedOptions.base64,
203
- exif: mergedOptions.exif,
204
- });
205
-
206
- if (result.canceled) {
207
- return null;
208
- }
209
-
210
- const selectedImages = result.assets.map(assetToSelectedImage);
211
-
212
- if (mergedOptions.allowsMultipleSelection) {
213
- setImages((prev) => [...prev, ...selectedImages]);
214
- } else {
215
- setImages(selectedImages);
216
- }
217
-
218
- return selectedImages;
219
- } catch (err) {
220
- const message = err instanceof Error ? err.message : "Failed to pick image";
221
- setError(message);
222
- console.error("[useImagePicker] Library error:", err);
223
- return null;
224
- } finally {
225
- setIsLoading(false);
226
- }
227
- },
228
- [requestLibraryPermission]
229
- );
230
-
231
- /**
232
- * Take photo with camera
233
- */
234
- const takePhoto = useCallback(
235
- async (options: ImagePickerOptions = {}): Promise<SelectedImage | null> => {
236
- setError(null);
237
- setIsLoading(true);
238
-
239
- try {
240
- const hasPermission = await requestCameraPermission();
241
- if (!hasPermission) {
242
- return null;
243
- }
244
-
245
- const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
246
-
247
- const result = await ImagePicker.launchCameraAsync({
248
- mediaTypes: mergedOptions.mediaTypes,
249
- allowsEditing: mergedOptions.allowsEditing,
250
- aspect: mergedOptions.aspect,
251
- quality: mergedOptions.quality,
252
- base64: mergedOptions.base64,
253
- exif: mergedOptions.exif,
254
- });
255
-
256
- if (result.canceled) {
257
- return null;
258
- }
259
-
260
- const selectedImage = assetToSelectedImage(result.assets[0]);
261
- setImages([selectedImage]);
262
-
263
- return selectedImage;
264
- } catch (err) {
265
- const message = err instanceof Error ? err.message : "Failed to take photo";
266
- setError(message);
267
- console.error("[useImagePicker] Camera error:", err);
268
- return null;
269
- } finally {
270
- setIsLoading(false);
271
- }
272
- },
273
- [requestCameraPermission]
274
- );
275
-
276
- /**
277
- * Show action sheet to choose source (library or camera)
278
- */
279
- const pickImage = useCallback(
280
- async (options: ImagePickerOptions = {}): Promise<SelectedImage[] | null> => {
281
- return new Promise((resolve) => {
282
- Alert.alert("Select Image", "Choose image source", [
283
- {
284
- text: "Camera",
285
- onPress: async () => {
286
- const result = await takePhoto(options);
287
- resolve(result ? [result] : null);
288
- },
289
- },
290
- {
291
- text: "Photo Library",
292
- onPress: async () => {
293
- const result = await pickFromLibrary(options);
294
- resolve(result);
295
- },
296
- },
297
- {
298
- text: "Cancel",
299
- style: "cancel",
300
- onPress: () => resolve(null),
301
- },
302
- ]);
303
- });
304
- },
305
- [takePhoto, pickFromLibrary]
306
- );
307
-
308
- /**
309
- * Clear all selected images
310
- */
311
- const clear = useCallback(() => {
312
- setImages([]);
313
- setError(null);
314
- }, []);
315
-
316
- /**
317
- * Remove image by index
318
- */
319
- const removeImage = useCallback((index: number) => {
320
- setImages((prev) => prev.filter((_, i) => i !== index));
321
- }, []);
322
-
323
- return {
324
- images,
325
- isLoading,
326
- error,
327
- pickFromLibrary,
328
- takePhoto,
329
- pickImage,
330
- clear,
331
- removeImage,
332
- };
333
- }
334
-
335
- /**
336
- * Utility to get file extension from URI
337
- */
338
- export function getFileExtension(uri: string): string {
339
- const match = uri.match(/\.(\w+)$/);
340
- return match ? match[1].toLowerCase() : "jpg";
341
- }
342
-
343
- /**
344
- * Utility to get MIME type from extension
345
- */
346
- export function getMimeType(extension: string): string {
347
- const mimeTypes: Record<string, string> = {
348
- jpg: "image/jpeg",
349
- jpeg: "image/jpeg",
350
- png: "image/png",
351
- gif: "image/gif",
352
- webp: "image/webp",
353
- heic: "image/heic",
354
- heif: "image/heif",
355
- };
356
- return mimeTypes[extension.toLowerCase()] || "image/jpeg";
357
- }
358
-
359
- /**
360
- * Prepare image for FormData upload
361
- */
362
- export function prepareImageForUpload(
363
- image: SelectedImage,
364
- fieldName = "image"
365
- ): { uri: string; type: string; name: string } {
366
- const extension = getFileExtension(image.uri);
367
- const type = image.type || getMimeType(extension);
368
- const name = image.fileName || `${fieldName}.${extension}`;
369
-
370
- return {
371
- uri: Platform.OS === "ios" ? image.uri.replace("file://", "") : image.uri,
372
- type,
373
- name,
374
- };
375
- }
1
+ /**
2
+ * @fileoverview Combined image pick, compress, and preview hook
3
+ * Integrates media picker and compression services with permission management.
4
+ * @module hooks/useImagePicker
5
+ */
6
+
7
+ import { useState, useCallback } from "react";
8
+ import { Alert, Platform } from "react-native";
9
+
10
+ import { usePermission } from "@/hooks/usePermission";
11
+ import {
12
+ pickFromLibrary as pickFromLib,
13
+ pickFromCamera as pickFromCam,
14
+ type PickedMedia,
15
+ } from "@/services/media/media-picker";
16
+ import { compressImage } from "@/services/media/compression";
17
+
18
+ /**
19
+ * Options for the useImagePicker hook
20
+ */
21
+ export interface UseImagePickerOptions {
22
+ /** Whether to compress images after picking (default: true) */
23
+ compress?: boolean;
24
+ /** Maximum width for compression (default: 1080) */
25
+ maxWidth?: number;
26
+ /** Maximum height for compression (default: 1080) */
27
+ maxHeight?: number;
28
+ /** Compression quality 0-1 (default: 0.7) */
29
+ quality?: number;
30
+ }
31
+
32
+ /**
33
+ * Return type for the useImagePicker hook
34
+ */
35
+ export interface UseImagePickerReturn {
36
+ /** Pick media from the photo library */
37
+ pickFromLibrary: () => Promise<PickedMedia | null>;
38
+ /** Take a photo with the camera */
39
+ pickFromCamera: () => Promise<PickedMedia | null>;
40
+ /** Currently selected media item */
41
+ selectedMedia: PickedMedia | null;
42
+ /** Whether a pick or compress operation is in progress */
43
+ isLoading: boolean;
44
+ /** Clear the selected media */
45
+ clear: () => void;
46
+ /** Camera permission state from usePermission */
47
+ cameraPermission: ReturnType<typeof usePermission>;
48
+ /** Media library permission state from usePermission */
49
+ mediaLibraryPermission: ReturnType<typeof usePermission>;
50
+ }
51
+
52
+ /**
53
+ * Hook for picking, compressing, and previewing images.
54
+ * Combines the media picker service with compression and centralized
55
+ * permission management via usePermission.
56
+ *
57
+ * @param options - Configuration for compression and behavior
58
+ * @returns Pick functions, selected media state, and permission info
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * function ProfilePhoto() {
63
+ * const { pickFromLibrary, pickFromCamera, selectedMedia, isLoading, clear } =
64
+ * useImagePicker({ maxWidth: 500, quality: 0.8 });
65
+ *
66
+ * return (
67
+ * <View>
68
+ * {selectedMedia ? (
69
+ * <>
70
+ * <Image source={{ uri: selectedMedia.uri }} style={{ width: 200, height: 200 }} />
71
+ * <Button onPress={clear}>Remove</Button>
72
+ * </>
73
+ * ) : (
74
+ * <>
75
+ * <Button onPress={pickFromLibrary}>Choose Photo</Button>
76
+ * <Button onPress={pickFromCamera}>Take Photo</Button>
77
+ * </>
78
+ * )}
79
+ * {isLoading && <ActivityIndicator />}
80
+ * </View>
81
+ * );
82
+ * }
83
+ * ```
84
+ */
85
+ export function useImagePicker(
86
+ options: UseImagePickerOptions = {}
87
+ ): UseImagePickerReturn {
88
+ const {
89
+ compress = true,
90
+ maxWidth = 1080,
91
+ maxHeight = 1080,
92
+ quality = 0.7,
93
+ } = options;
94
+
95
+ const [selectedMedia, setSelectedMedia] = useState<PickedMedia | null>(null);
96
+ const [isLoading, setIsLoading] = useState(false);
97
+
98
+ const cameraPermission = usePermission("camera");
99
+ const mediaLibraryPermission = usePermission("mediaLibrary");
100
+
101
+ /**
102
+ * Ensure a permission is granted, requesting it if needed
103
+ */
104
+ const ensurePermission = useCallback(
105
+ async (
106
+ permission: ReturnType<typeof usePermission>,
107
+ label: string
108
+ ): Promise<boolean> => {
109
+ if (permission.isGranted) {
110
+ return true;
111
+ }
112
+
113
+ if (permission.isBlocked) {
114
+ Alert.alert(
115
+ `${label} Permission Required`,
116
+ `Please allow ${label.toLowerCase()} access in your device settings.`,
117
+ [
118
+ { text: "Cancel", style: "cancel" },
119
+ { text: "Open Settings", onPress: () => permission.openSettings() },
120
+ ]
121
+ );
122
+ return false;
123
+ }
124
+
125
+ // Request the permission and use the returned status directly
126
+ // to avoid reading stale React state after an async operation.
127
+ const resultStatus = await permission.request();
128
+ return resultStatus === "granted";
129
+ },
130
+ []
131
+ );
132
+
133
+ /**
134
+ * Optionally compress a picked image
135
+ */
136
+ const maybeCompress = useCallback(
137
+ async (media: PickedMedia): Promise<PickedMedia> => {
138
+ if (!compress || media.type !== "image") {
139
+ return media;
140
+ }
141
+
142
+ try {
143
+ const result = await compressImage(media.uri, {
144
+ maxWidth,
145
+ maxHeight,
146
+ quality,
147
+ });
148
+
149
+ return {
150
+ ...media,
151
+ uri: result.uri,
152
+ width: result.width,
153
+ height: result.height,
154
+ };
155
+ } catch (err) {
156
+ console.warn(
157
+ "[useImagePicker] Compression failed, using original:",
158
+ err
159
+ );
160
+ return media;
161
+ }
162
+ },
163
+ [compress, maxWidth, maxHeight, quality]
164
+ );
165
+
166
+ /**
167
+ * Pick media from the photo library
168
+ */
169
+ const pickFromLibrary = useCallback(async (): Promise<PickedMedia | null> => {
170
+ setIsLoading(true);
171
+ try {
172
+ const hasPermission = await ensurePermission(
173
+ mediaLibraryPermission,
174
+ "Photo Library"
175
+ );
176
+ if (!hasPermission) {
177
+ return null;
178
+ }
179
+
180
+ const items = await pickFromLib({ quality: 0.8 });
181
+ if (items.length === 0) {
182
+ return null;
183
+ }
184
+
185
+ const compressed = await maybeCompress(items[0]);
186
+ setSelectedMedia(compressed);
187
+ return compressed;
188
+ } catch (err) {
189
+ console.error("[useImagePicker] Library pick error:", err);
190
+ return null;
191
+ } finally {
192
+ setIsLoading(false);
193
+ }
194
+ }, [ensurePermission, mediaLibraryPermission, maybeCompress]);
195
+
196
+ /**
197
+ * Take a photo with the camera
198
+ */
199
+ const pickFromCamera = useCallback(async (): Promise<PickedMedia | null> => {
200
+ setIsLoading(true);
201
+ try {
202
+ const hasPermission = await ensurePermission(cameraPermission, "Camera");
203
+ if (!hasPermission) {
204
+ return null;
205
+ }
206
+
207
+ const item = await pickFromCam({ quality: 0.8 });
208
+ if (!item) {
209
+ return null;
210
+ }
211
+
212
+ const compressed = await maybeCompress(item);
213
+ setSelectedMedia(compressed);
214
+ return compressed;
215
+ } catch (err) {
216
+ console.error("[useImagePicker] Camera pick error:", err);
217
+ return null;
218
+ } finally {
219
+ setIsLoading(false);
220
+ }
221
+ }, [ensurePermission, cameraPermission, maybeCompress]);
222
+
223
+ /**
224
+ * Clear the selected media
225
+ */
226
+ const clear = useCallback(() => {
227
+ setSelectedMedia(null);
228
+ }, []);
229
+
230
+ return {
231
+ pickFromLibrary,
232
+ pickFromCamera,
233
+ selectedMedia,
234
+ isLoading,
235
+ clear,
236
+ cameraPermission,
237
+ mediaLibraryPermission,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Utility to get file extension from URI
243
+ */
244
+ export function getFileExtension(uri: string): string {
245
+ const match = uri.match(/\.(\w+)$/);
246
+ return match ? match[1].toLowerCase() : "jpg";
247
+ }
248
+
249
+ /**
250
+ * Utility to get MIME type from extension
251
+ */
252
+ export function getMimeType(extension: string): string {
253
+ const mimeTypes: Record<string, string> = {
254
+ jpg: "image/jpeg",
255
+ jpeg: "image/jpeg",
256
+ png: "image/png",
257
+ gif: "image/gif",
258
+ webp: "image/webp",
259
+ heic: "image/heic",
260
+ heif: "image/heif",
261
+ };
262
+ return mimeTypes[extension.toLowerCase()] || "image/jpeg";
263
+ }
264
+
265
+ /**
266
+ * Prepare image for FormData upload
267
+ */
268
+ export function prepareImageForUpload(
269
+ media: PickedMedia,
270
+ fieldName = "image"
271
+ ): { uri: string; type: string; name: string } {
272
+ const extension = getFileExtension(media.uri);
273
+ const type = getMimeType(extension);
274
+ const name = media.fileName || `${fieldName}.${extension}`;
275
+
276
+ return {
277
+ uri: Platform.OS === "ios" ? media.uri.replace("file://", "") : media.uri,
278
+ type,
279
+ name,
280
+ };
281
+ }