@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.
- package/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -21
- package/README.md +446 -402
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -418
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -30
- package/constants/config.ts +135 -97
- 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/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -40
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +5 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -375
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- 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 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -176
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- package/utils/withAccessibility.tsx +272 -272
package/hooks/useImagePicker.ts
CHANGED
|
@@ -1,375 +1,281 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview
|
|
3
|
-
*
|
|
4
|
-
* @module hooks/useImagePicker
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { useState, useCallback } from "react";
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|