@croacroa/react-native-template 2.0.1 → 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 -0
  10. package/README.md +446 -399
  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 -0
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -23
  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 -27
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +64 -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 -0
  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 -175
  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
@@ -68,8 +68,10 @@ describe("useAuth", () => {
68
68
 
69
69
  it("should load stored auth on mount", async () => {
70
70
  mockSecureStore.getItemAsync.mockImplementation((key) => {
71
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
72
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
71
+ if (key === "auth_tokens")
72
+ return Promise.resolve(JSON.stringify(mockTokens));
73
+ if (key === "auth_user")
74
+ return Promise.resolve(JSON.stringify(mockUser));
73
75
  return Promise.resolve(null);
74
76
  });
75
77
 
@@ -85,8 +87,10 @@ describe("useAuth", () => {
85
87
 
86
88
  it("should attempt refresh if stored tokens are expired", async () => {
87
89
  mockSecureStore.getItemAsync.mockImplementation((key) => {
88
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(expiredTokens));
89
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
90
+ if (key === "auth_tokens")
91
+ return Promise.resolve(JSON.stringify(expiredTokens));
92
+ if (key === "auth_user")
93
+ return Promise.resolve(JSON.stringify(mockUser));
90
94
  return Promise.resolve(null);
91
95
  });
92
96
 
@@ -102,9 +106,13 @@ describe("useAuth", () => {
102
106
  });
103
107
 
104
108
  it("should handle storage errors gracefully", async () => {
105
- const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
109
+ const consoleSpy = jest
110
+ .spyOn(console, "error")
111
+ .mockImplementation(() => {});
106
112
 
107
- mockSecureStore.getItemAsync.mockRejectedValue(new Error("Storage error"));
113
+ mockSecureStore.getItemAsync.mockRejectedValue(
114
+ new Error("Storage error")
115
+ );
108
116
 
109
117
  const { result } = renderHook(() => useAuth(), { wrapper });
110
118
 
@@ -173,7 +181,10 @@ describe("useAuth", () => {
173
181
  await result.current.signIn("test@example.com", "password123");
174
182
  });
175
183
 
176
- expect(toast.success).toHaveBeenCalledWith("Welcome back!", expect.any(String));
184
+ expect(toast.success).toHaveBeenCalledWith(
185
+ "Welcome back!",
186
+ expect.any(String)
187
+ );
177
188
  });
178
189
 
179
190
  it("should show error toast on failed sign in", async () => {
@@ -192,7 +203,10 @@ describe("useAuth", () => {
192
203
  }
193
204
  });
194
205
 
195
- expect(toast.error).toHaveBeenCalledWith("Sign in failed", expect.any(String));
206
+ expect(toast.error).toHaveBeenCalledWith(
207
+ "Sign in failed",
208
+ expect.any(String)
209
+ );
196
210
  });
197
211
  });
198
212
 
@@ -205,7 +219,11 @@ describe("useAuth", () => {
205
219
  });
206
220
 
207
221
  await act(async () => {
208
- await result.current.signUp("newuser@example.com", "password123", "New User");
222
+ await result.current.signUp(
223
+ "newuser@example.com",
224
+ "password123",
225
+ "New User"
226
+ );
209
227
  });
210
228
 
211
229
  expect(result.current.isAuthenticated).toBe(true);
@@ -225,7 +243,10 @@ describe("useAuth", () => {
225
243
  await result.current.signUp("new@example.com", "password", "Test");
226
244
  });
227
245
 
228
- expect(toast.success).toHaveBeenCalledWith("Account created!", expect.any(String));
246
+ expect(toast.success).toHaveBeenCalledWith(
247
+ "Account created!",
248
+ expect.any(String)
249
+ );
229
250
  });
230
251
  });
231
252
 
@@ -233,8 +254,10 @@ describe("useAuth", () => {
233
254
  it("should sign out and clear storage", async () => {
234
255
  // Start with authenticated state
235
256
  mockSecureStore.getItemAsync.mockImplementation((key) => {
236
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
237
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
257
+ if (key === "auth_tokens")
258
+ return Promise.resolve(JSON.stringify(mockTokens));
259
+ if (key === "auth_user")
260
+ return Promise.resolve(JSON.stringify(mockUser));
238
261
  return Promise.resolve(null);
239
262
  });
240
263
 
@@ -250,14 +273,18 @@ describe("useAuth", () => {
250
273
 
251
274
  expect(result.current.isAuthenticated).toBe(false);
252
275
  expect(result.current.user).toBeNull();
253
- expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_tokens");
276
+ expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith(
277
+ "auth_tokens"
278
+ );
254
279
  expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_user");
255
280
  });
256
281
 
257
282
  it("should redirect to login after sign out", async () => {
258
283
  mockSecureStore.getItemAsync.mockImplementation((key) => {
259
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
260
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
284
+ if (key === "auth_tokens")
285
+ return Promise.resolve(JSON.stringify(mockTokens));
286
+ if (key === "auth_user")
287
+ return Promise.resolve(JSON.stringify(mockUser));
261
288
  return Promise.resolve(null);
262
289
  });
263
290
 
@@ -278,8 +305,10 @@ describe("useAuth", () => {
278
305
  const { toast } = require("@/utils/toast");
279
306
 
280
307
  mockSecureStore.getItemAsync.mockImplementation((key) => {
281
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
282
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
308
+ if (key === "auth_tokens")
309
+ return Promise.resolve(JSON.stringify(mockTokens));
310
+ if (key === "auth_user")
311
+ return Promise.resolve(JSON.stringify(mockUser));
283
312
  return Promise.resolve(null);
284
313
  });
285
314
 
@@ -300,8 +329,10 @@ describe("useAuth", () => {
300
329
  describe("updateUser", () => {
301
330
  it("should update user data", async () => {
302
331
  mockSecureStore.getItemAsync.mockImplementation((key) => {
303
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
304
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
332
+ if (key === "auth_tokens")
333
+ return Promise.resolve(JSON.stringify(mockTokens));
334
+ if (key === "auth_user")
335
+ return Promise.resolve(JSON.stringify(mockUser));
305
336
  return Promise.resolve(null);
306
337
  });
307
338
 
@@ -337,15 +368,19 @@ describe("useAuth", () => {
337
368
 
338
369
  expect(result.current.user).toBeNull();
339
370
  // setItemAsync should not have been called for user update
340
- expect(mockSecureStore.setItemAsync.mock.calls.length).toBe(setItemCallsBefore);
371
+ expect(mockSecureStore.setItemAsync.mock.calls.length).toBe(
372
+ setItemCallsBefore
373
+ );
341
374
  });
342
375
  });
343
376
 
344
377
  describe("refreshSession", () => {
345
378
  it("should refresh session when tokens exist", async () => {
346
379
  mockSecureStore.getItemAsync.mockImplementation((key) => {
347
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
348
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
380
+ if (key === "auth_tokens")
381
+ return Promise.resolve(JSON.stringify(mockTokens));
382
+ if (key === "auth_user")
383
+ return Promise.resolve(JSON.stringify(mockUser));
349
384
  return Promise.resolve(null);
350
385
  });
351
386
 
@@ -390,8 +425,10 @@ describe("useAuth", () => {
390
425
 
391
426
  it("should setup refresh interval when tokens exist", async () => {
392
427
  mockSecureStore.getItemAsync.mockImplementation((key) => {
393
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(mockTokens));
394
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
428
+ if (key === "auth_tokens")
429
+ return Promise.resolve(JSON.stringify(mockTokens));
430
+ if (key === "auth_user")
431
+ return Promise.resolve(JSON.stringify(mockUser));
395
432
  return Promise.resolve(null);
396
433
  });
397
434
 
@@ -412,8 +449,10 @@ describe("useAuth", () => {
412
449
  };
413
450
 
414
451
  mockSecureStore.getItemAsync.mockImplementation((key) => {
415
- if (key === "auth_tokens") return Promise.resolve(JSON.stringify(soonToExpireTokens));
416
- if (key === "auth_user") return Promise.resolve(JSON.stringify(mockUser));
452
+ if (key === "auth_tokens")
453
+ return Promise.resolve(JSON.stringify(soonToExpireTokens));
454
+ if (key === "auth_user")
455
+ return Promise.resolve(JSON.stringify(mockUser));
417
456
  return Promise.resolve(null);
418
457
  });
419
458
 
@@ -434,7 +473,9 @@ describe("useAuth", () => {
434
473
  describe("error handling", () => {
435
474
  it("should throw error when useAuth is used outside AuthProvider", () => {
436
475
  // Suppress console.error for this test
437
- const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
476
+ const consoleSpy = jest
477
+ .spyOn(console, "error")
478
+ .mockImplementation(() => {});
438
479
 
439
480
  expect(() => {
440
481
  renderHook(() => useAuth());
@@ -483,7 +524,9 @@ describe("getAuthToken", () => {
483
524
  });
484
525
 
485
526
  it("should handle storage errors gracefully", async () => {
486
- const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
527
+ const consoleSpy = jest
528
+ .spyOn(console, "error")
529
+ .mockImplementation(() => {});
487
530
 
488
531
  (SecureStore.getItemAsync as jest.Mock).mockRejectedValue(
489
532
  new Error("Storage error")
@@ -0,0 +1,318 @@
1
+ import { renderHook, act } from "@testing-library/react-native";
2
+
3
+ import { useImagePicker } from "@/hooks/useImagePicker";
4
+ import { useUpload } from "@/hooks/useUpload";
5
+ import type { PickedMedia } from "@/services/media/media-picker";
6
+ import type { UploadResult } from "@/services/media/media-upload";
7
+
8
+ // Mock usePermission
9
+ const mockRequest = jest.fn();
10
+ const mockOpenSettings = jest.fn();
11
+
12
+ jest.mock("@/hooks/usePermission", () => ({
13
+ usePermission: jest.fn((type: string) => ({
14
+ status: "granted",
15
+ isGranted: true,
16
+ isBlocked: false,
17
+ isLoading: false,
18
+ config: { title: `${type} Access`, message: "Test message", icon: "test" },
19
+ request: mockRequest,
20
+ openSettings: mockOpenSettings,
21
+ refresh: jest.fn(),
22
+ })),
23
+ }));
24
+
25
+ // Mock media picker
26
+ const mockPickFromLibrary = jest.fn();
27
+ const mockPickFromCamera = jest.fn();
28
+
29
+ jest.mock("@/services/media/media-picker", () => ({
30
+ pickFromLibrary: (...args: any[]) => mockPickFromLibrary(...args),
31
+ pickFromCamera: (...args: any[]) => mockPickFromCamera(...args),
32
+ }));
33
+
34
+ // Mock compression
35
+ const mockCompressImage = jest.fn();
36
+
37
+ jest.mock("@/services/media/compression", () => ({
38
+ compressImage: (...args: any[]) => mockCompressImage(...args),
39
+ }));
40
+
41
+ // Mock media upload
42
+ const mockUploadFile = jest.fn();
43
+
44
+ jest.mock("@/services/media/media-upload", () => ({
45
+ uploadFile: (...args: any[]) => mockUploadFile(...args),
46
+ }));
47
+
48
+ // Mock Alert
49
+ jest.mock("react-native", () => {
50
+ const RN = jest.requireActual("react-native");
51
+ RN.Alert.alert = jest.fn();
52
+ return RN;
53
+ });
54
+
55
+ // Test data
56
+ const mockMedia: PickedMedia = {
57
+ uri: "file:///test/photo.jpg",
58
+ width: 1920,
59
+ height: 1080,
60
+ type: "image",
61
+ fileName: "photo.jpg",
62
+ };
63
+
64
+ const mockCompressedMedia = {
65
+ uri: "file:///test/compressed.jpg",
66
+ width: 1080,
67
+ height: 607,
68
+ };
69
+
70
+ describe("useImagePicker", () => {
71
+ beforeEach(() => {
72
+ jest.clearAllMocks();
73
+ mockRequest.mockResolvedValue("granted");
74
+ mockCompressImage.mockResolvedValue(mockCompressedMedia);
75
+
76
+ // Reset usePermission to default "granted" state
77
+ const { usePermission } = require("@/hooks/usePermission");
78
+ (usePermission as jest.Mock).mockImplementation((type: string) => ({
79
+ status: "granted",
80
+ isGranted: true,
81
+ isBlocked: false,
82
+ isLoading: false,
83
+ config: {
84
+ title: `${type} Access`,
85
+ message: "Test message",
86
+ icon: "test",
87
+ },
88
+ request: mockRequest,
89
+ openSettings: mockOpenSettings,
90
+ refresh: jest.fn(),
91
+ }));
92
+ });
93
+
94
+ it("should pick and compress image from library", async () => {
95
+ mockPickFromLibrary.mockResolvedValue([mockMedia]);
96
+
97
+ const { result } = renderHook(() => useImagePicker());
98
+
99
+ let picked: PickedMedia | null = null;
100
+ await act(async () => {
101
+ picked = await result.current.pickFromLibrary();
102
+ });
103
+
104
+ expect(mockPickFromLibrary).toHaveBeenCalled();
105
+ expect(mockCompressImage).toHaveBeenCalledWith(mockMedia.uri, {
106
+ maxWidth: 1080,
107
+ maxHeight: 1080,
108
+ quality: 0.7,
109
+ });
110
+ expect(picked).toBeTruthy();
111
+ expect(picked!.uri).toBe(mockCompressedMedia.uri);
112
+ expect(result.current.selectedMedia).toBeTruthy();
113
+ });
114
+
115
+ it("should pick and compress image from camera", async () => {
116
+ mockPickFromCamera.mockResolvedValue(mockMedia);
117
+
118
+ const { result } = renderHook(() => useImagePicker());
119
+
120
+ let picked: PickedMedia | null = null;
121
+ await act(async () => {
122
+ picked = await result.current.pickFromCamera();
123
+ });
124
+
125
+ expect(mockPickFromCamera).toHaveBeenCalled();
126
+ expect(mockCompressImage).toHaveBeenCalled();
127
+ expect(picked).toBeTruthy();
128
+ expect(result.current.selectedMedia).toBeTruthy();
129
+ });
130
+
131
+ it("should return preview URI after pick", async () => {
132
+ mockPickFromLibrary.mockResolvedValue([mockMedia]);
133
+
134
+ const { result } = renderHook(() => useImagePicker());
135
+
136
+ await act(async () => {
137
+ await result.current.pickFromLibrary();
138
+ });
139
+
140
+ expect(result.current.selectedMedia?.uri).toBe(mockCompressedMedia.uri);
141
+ });
142
+
143
+ it("should clear state when clear() is called", async () => {
144
+ mockPickFromLibrary.mockResolvedValue([mockMedia]);
145
+
146
+ const { result } = renderHook(() => useImagePicker());
147
+
148
+ await act(async () => {
149
+ await result.current.pickFromLibrary();
150
+ });
151
+
152
+ expect(result.current.selectedMedia).toBeTruthy();
153
+
154
+ act(() => {
155
+ result.current.clear();
156
+ });
157
+
158
+ expect(result.current.selectedMedia).toBeNull();
159
+ });
160
+
161
+ it("should handle permission denied", async () => {
162
+ const { usePermission } = require("@/hooks/usePermission");
163
+ (usePermission as jest.Mock).mockImplementation((type: string) => ({
164
+ status: "denied",
165
+ isGranted: false,
166
+ isBlocked: false,
167
+ isLoading: false,
168
+ config: {
169
+ title: `${type} Access`,
170
+ message: "Test message",
171
+ icon: "test",
172
+ },
173
+ request: mockRequest,
174
+ openSettings: mockOpenSettings,
175
+ refresh: jest.fn(),
176
+ }));
177
+
178
+ mockRequest.mockResolvedValue("denied");
179
+
180
+ const { result } = renderHook(() => useImagePicker());
181
+
182
+ let picked: PickedMedia | null = null;
183
+ await act(async () => {
184
+ picked = await result.current.pickFromLibrary();
185
+ });
186
+
187
+ expect(picked).toBeNull();
188
+ expect(mockPickFromLibrary).not.toHaveBeenCalled();
189
+ });
190
+ });
191
+
192
+ describe("useUpload", () => {
193
+ beforeEach(() => {
194
+ jest.clearAllMocks();
195
+ });
196
+
197
+ it("should call uploadFile and track progress", async () => {
198
+ const mockResult: UploadResult = { status: 200, body: '{"ok":true}' };
199
+ mockUploadFile.mockReturnValue({
200
+ promise: Promise.resolve(mockResult),
201
+ abort: jest.fn(),
202
+ });
203
+
204
+ const { result } = renderHook(() =>
205
+ useUpload({ url: "https://api.example.com/upload" })
206
+ );
207
+
208
+ let uploadResult: UploadResult | null = null;
209
+ await act(async () => {
210
+ uploadResult = await result.current.upload("file:///test/photo.jpg");
211
+ });
212
+
213
+ expect(mockUploadFile).toHaveBeenCalledWith(
214
+ expect.objectContaining({
215
+ url: "https://api.example.com/upload",
216
+ uri: "file:///test/photo.jpg",
217
+ fieldName: "file",
218
+ })
219
+ );
220
+ expect(uploadResult).toEqual(mockResult);
221
+ });
222
+
223
+ it("should report progress updates", async () => {
224
+ let onProgressCallback: ((p: any) => void) | null = null;
225
+ mockUploadFile.mockImplementation((options: any) => {
226
+ onProgressCallback = options.onProgress;
227
+ return {
228
+ promise: new Promise((resolve) => {
229
+ // Simulate progress then resolve
230
+ setTimeout(() => {
231
+ if (onProgressCallback) {
232
+ onProgressCallback({ loaded: 50, total: 100, percentage: 50 });
233
+ }
234
+ resolve({ status: 200, body: "ok" });
235
+ }, 0);
236
+ }),
237
+ abort: jest.fn(),
238
+ };
239
+ });
240
+
241
+ const { result } = renderHook(() =>
242
+ useUpload({ url: "https://api.example.com/upload" })
243
+ );
244
+
245
+ await act(async () => {
246
+ await result.current.upload("file:///test/photo.jpg");
247
+ });
248
+
249
+ // The onProgress callback was provided to uploadFile
250
+ expect(onProgressCallback).toBeDefined();
251
+ });
252
+
253
+ it("should cancel upload when cancel() is called", async () => {
254
+ const mockAbort = jest.fn();
255
+ mockUploadFile.mockReturnValue({
256
+ promise: new Promise(() => {}), // Never resolves
257
+ abort: mockAbort,
258
+ });
259
+
260
+ const { result } = renderHook(() =>
261
+ useUpload({ url: "https://api.example.com/upload" })
262
+ );
263
+
264
+ act(() => {
265
+ result.current.upload("file:///test/photo.jpg");
266
+ });
267
+
268
+ act(() => {
269
+ result.current.cancel();
270
+ });
271
+
272
+ expect(mockAbort).toHaveBeenCalled();
273
+ });
274
+
275
+ it("should reset all state when reset() is called", async () => {
276
+ const mockResult: UploadResult = { status: 200, body: '{"ok":true}' };
277
+ mockUploadFile.mockReturnValue({
278
+ promise: Promise.resolve(mockResult),
279
+ abort: jest.fn(),
280
+ });
281
+
282
+ const { result } = renderHook(() =>
283
+ useUpload({ url: "https://api.example.com/upload" })
284
+ );
285
+
286
+ await act(async () => {
287
+ await result.current.upload("file:///test/photo.jpg");
288
+ });
289
+
290
+ act(() => {
291
+ result.current.reset();
292
+ });
293
+
294
+ expect(result.current.progress).toBeNull();
295
+ expect(result.current.isUploading).toBe(false);
296
+ expect(result.current.error).toBeNull();
297
+ });
298
+
299
+ it("should handle upload error", async () => {
300
+ mockUploadFile.mockReturnValue({
301
+ promise: Promise.reject(new Error("Network error")),
302
+ abort: jest.fn(),
303
+ });
304
+
305
+ const { result } = renderHook(() =>
306
+ useUpload({ url: "https://api.example.com/upload" })
307
+ );
308
+
309
+ let uploadResult: UploadResult | null = null;
310
+ await act(async () => {
311
+ uploadResult = await result.current.upload("file:///test/photo.jpg");
312
+ });
313
+
314
+ expect(uploadResult).toBeNull();
315
+ expect(result.current.error).toBe("Network error");
316
+ expect(result.current.isUploading).toBe(false);
317
+ });
318
+ });