@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
@@ -0,0 +1,307 @@
1
+ import { renderHook, act, waitFor } from "@testing-library/react-native";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { ReactNode } from "react";
4
+
5
+ import { useProducts } from "@/hooks/useProducts";
6
+ import { usePurchase } from "@/hooks/usePurchase";
7
+ import { useSubscription } from "@/hooks/useSubscription";
8
+ import { Payments } from "@/services/payments/payment-adapter";
9
+ import type {
10
+ Product,
11
+ Purchase,
12
+ SubscriptionInfo,
13
+ } from "@/services/payments/types";
14
+
15
+ // Mock Payments facade
16
+ jest.mock("@/services/payments/payment-adapter", () => ({
17
+ Payments: {
18
+ getProducts: jest.fn(),
19
+ purchase: jest.fn(),
20
+ restorePurchases: jest.fn(),
21
+ getSubscriptionStatus: jest.fn(),
22
+ },
23
+ }));
24
+
25
+ const mockPayments = Payments as jest.Mocked<typeof Payments>;
26
+
27
+ // Query client wrapper for TanStack Query hooks
28
+ const queryClient = new QueryClient({
29
+ defaultOptions: {
30
+ queries: {
31
+ retry: false,
32
+ },
33
+ },
34
+ });
35
+
36
+ const wrapper = ({ children }: { children: ReactNode }) => (
37
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
38
+ );
39
+
40
+ // Test data
41
+ const mockProducts: Product[] = [
42
+ {
43
+ id: "premium_monthly",
44
+ title: "Premium Monthly",
45
+ description: "Monthly premium subscription",
46
+ price: 999,
47
+ priceString: "$9.99",
48
+ currency: "USD",
49
+ type: "subscription",
50
+ subscriptionPeriod: "monthly",
51
+ },
52
+ {
53
+ id: "premium_yearly",
54
+ title: "Premium Yearly",
55
+ description: "Yearly premium subscription",
56
+ price: 4999,
57
+ priceString: "$49.99",
58
+ currency: "USD",
59
+ type: "subscription",
60
+ subscriptionPeriod: "yearly",
61
+ },
62
+ ];
63
+
64
+ const mockPurchase: Purchase = {
65
+ id: "txn_123",
66
+ productId: "premium_monthly",
67
+ transactionDate: "2024-01-01T00:00:00.000Z",
68
+ };
69
+
70
+ describe("useProducts", () => {
71
+ beforeEach(() => {
72
+ jest.clearAllMocks();
73
+ queryClient.clear();
74
+ });
75
+
76
+ it("should return products list from Payments.getProducts", async () => {
77
+ mockPayments.getProducts.mockResolvedValue(mockProducts);
78
+
79
+ const { result } = renderHook(
80
+ () => useProducts(["premium_monthly", "premium_yearly"]),
81
+ { wrapper }
82
+ );
83
+
84
+ await waitFor(() => {
85
+ expect(result.current.isSuccess).toBe(true);
86
+ });
87
+
88
+ expect(result.current.data).toEqual(mockProducts);
89
+ expect(mockPayments.getProducts).toHaveBeenCalledWith([
90
+ "premium_monthly",
91
+ "premium_yearly",
92
+ ]);
93
+ });
94
+
95
+ it("should handle loading state", () => {
96
+ mockPayments.getProducts.mockReturnValue(new Promise(() => {})); // Never resolves
97
+
98
+ const { result } = renderHook(() => useProducts(["premium_monthly"]), {
99
+ wrapper,
100
+ });
101
+
102
+ expect(result.current.isLoading).toBe(true);
103
+ expect(result.current.data).toBeUndefined();
104
+ });
105
+
106
+ it("should handle error state", async () => {
107
+ const consoleSpy = jest
108
+ .spyOn(console, "error")
109
+ .mockImplementation(() => {});
110
+ mockPayments.getProducts.mockRejectedValue(new Error("Store unavailable"));
111
+
112
+ const { result } = renderHook(() => useProducts(["premium_monthly"]), {
113
+ wrapper,
114
+ });
115
+
116
+ await waitFor(() => {
117
+ expect(result.current.isError).toBe(true);
118
+ });
119
+
120
+ expect(result.current.error?.message).toBe("Store unavailable");
121
+ consoleSpy.mockRestore();
122
+ });
123
+ });
124
+
125
+ describe("usePurchase", () => {
126
+ beforeEach(() => {
127
+ jest.clearAllMocks();
128
+ });
129
+
130
+ it("should call Payments.purchase with productId", async () => {
131
+ mockPayments.purchase.mockResolvedValue(mockPurchase);
132
+
133
+ const { result } = renderHook(() => usePurchase());
134
+
135
+ let purchaseResult: Purchase | null = null;
136
+ await act(async () => {
137
+ purchaseResult = await result.current.purchase("premium_monthly");
138
+ });
139
+
140
+ expect(mockPayments.purchase).toHaveBeenCalledWith("premium_monthly");
141
+ expect(purchaseResult).toEqual(mockPurchase);
142
+ });
143
+
144
+ it("should return loading state during purchase", async () => {
145
+ let resolvePromise: (value: Purchase) => void;
146
+ mockPayments.purchase.mockReturnValue(
147
+ new Promise((resolve) => {
148
+ resolvePromise = resolve;
149
+ })
150
+ );
151
+
152
+ const { result } = renderHook(() => usePurchase());
153
+
154
+ expect(result.current.isLoading).toBe(false);
155
+
156
+ let purchasePromise: Promise<Purchase | null>;
157
+ act(() => {
158
+ purchasePromise = result.current.purchase("premium_monthly");
159
+ });
160
+
161
+ // isLoading should be true during the purchase
162
+ expect(result.current.isLoading).toBe(true);
163
+
164
+ await act(async () => {
165
+ resolvePromise!(mockPurchase);
166
+ await purchasePromise!;
167
+ });
168
+
169
+ expect(result.current.isLoading).toBe(false);
170
+ });
171
+
172
+ it("should handle purchase error", async () => {
173
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
174
+ mockPayments.purchase.mockRejectedValue(new Error("Payment declined"));
175
+
176
+ const { result } = renderHook(() => usePurchase());
177
+
178
+ let purchaseResult: Purchase | null = null;
179
+ await act(async () => {
180
+ purchaseResult = await result.current.purchase("premium_monthly");
181
+ });
182
+
183
+ expect(purchaseResult).toBeNull();
184
+ expect(result.current.error?.message).toBe("Payment declined");
185
+ expect(result.current.isLoading).toBe(false);
186
+ consoleSpy.mockRestore();
187
+ });
188
+
189
+ it("should call Payments.restorePurchases on restore", async () => {
190
+ mockPayments.restorePurchases.mockResolvedValue([mockPurchase]);
191
+
192
+ const { result } = renderHook(() => usePurchase());
193
+
194
+ let restoreResult: Purchase[] = [];
195
+ await act(async () => {
196
+ restoreResult = await result.current.restore();
197
+ });
198
+
199
+ expect(mockPayments.restorePurchases).toHaveBeenCalled();
200
+ expect(restoreResult).toEqual([mockPurchase]);
201
+ });
202
+ });
203
+
204
+ describe("useSubscription", () => {
205
+ beforeEach(() => {
206
+ jest.clearAllMocks();
207
+ queryClient.clear();
208
+ });
209
+
210
+ it("should return subscription info", async () => {
211
+ const mockSubInfo: SubscriptionInfo = {
212
+ status: "active",
213
+ productId: "premium_monthly",
214
+ expiresAt: "2025-01-01T00:00:00.000Z",
215
+ willRenew: true,
216
+ };
217
+ mockPayments.getSubscriptionStatus.mockResolvedValue(mockSubInfo);
218
+
219
+ const { result } = renderHook(() => useSubscription(), { wrapper });
220
+
221
+ await waitFor(() => {
222
+ expect(result.current.isSuccess).toBe(true);
223
+ });
224
+
225
+ expect(result.current.data).toEqual(mockSubInfo);
226
+ });
227
+
228
+ it("should set isActive to true for active status", async () => {
229
+ mockPayments.getSubscriptionStatus.mockResolvedValue({
230
+ status: "active",
231
+ productId: "premium_monthly",
232
+ expiresAt: "2025-01-01T00:00:00.000Z",
233
+ willRenew: true,
234
+ });
235
+
236
+ const { result } = renderHook(() => useSubscription(), { wrapper });
237
+
238
+ await waitFor(() => {
239
+ expect(result.current.isSuccess).toBe(true);
240
+ });
241
+
242
+ expect(result.current.isActive).toBe(true);
243
+ });
244
+
245
+ it("should set isActive to true for grace_period status", async () => {
246
+ mockPayments.getSubscriptionStatus.mockResolvedValue({
247
+ status: "grace_period",
248
+ productId: "premium_monthly",
249
+ expiresAt: "2025-01-01T00:00:00.000Z",
250
+ willRenew: false,
251
+ });
252
+
253
+ const { result } = renderHook(() => useSubscription(), { wrapper });
254
+
255
+ await waitFor(() => {
256
+ expect(result.current.isSuccess).toBe(true);
257
+ });
258
+
259
+ expect(result.current.isActive).toBe(true);
260
+ expect(result.current.isPro).toBe(false);
261
+ });
262
+
263
+ it("should set isPro to true only for active status", async () => {
264
+ mockPayments.getSubscriptionStatus.mockResolvedValue({
265
+ status: "active",
266
+ productId: "premium_monthly",
267
+ expiresAt: "2025-01-01T00:00:00.000Z",
268
+ willRenew: true,
269
+ });
270
+
271
+ const { result } = renderHook(() => useSubscription(), { wrapper });
272
+
273
+ await waitFor(() => {
274
+ expect(result.current.isSuccess).toBe(true);
275
+ });
276
+
277
+ expect(result.current.isPro).toBe(true);
278
+ });
279
+
280
+ it("should set isActive to false for expired status", async () => {
281
+ mockPayments.getSubscriptionStatus.mockResolvedValue({
282
+ status: "expired",
283
+ productId: "premium_monthly",
284
+ expiresAt: "2024-01-01T00:00:00.000Z",
285
+ willRenew: false,
286
+ });
287
+
288
+ const { result } = renderHook(() => useSubscription(), { wrapper });
289
+
290
+ await waitFor(() => {
291
+ expect(result.current.isSuccess).toBe(true);
292
+ });
293
+
294
+ expect(result.current.isActive).toBe(false);
295
+ expect(result.current.isPro).toBe(false);
296
+ });
297
+
298
+ it("should handle loading state", () => {
299
+ mockPayments.getSubscriptionStatus.mockReturnValue(new Promise(() => {}));
300
+
301
+ const { result } = renderHook(() => useSubscription(), { wrapper });
302
+
303
+ expect(result.current.isLoading).toBe(true);
304
+ expect(result.current.isActive).toBe(false);
305
+ expect(result.current.isPro).toBe(false);
306
+ });
307
+ });
@@ -0,0 +1,230 @@
1
+ import { renderHook, act, waitFor } from "@testing-library/react-native";
2
+ import { AppState } from "react-native";
3
+
4
+ import { usePermission } from "@/hooks/usePermission";
5
+ import { PermissionManager } from "@/services/permissions/permission-manager";
6
+ import { DEFAULT_PERMISSION_CONFIGS } from "@/services/permissions/types";
7
+
8
+ // Mock PermissionManager
9
+ jest.mock("@/services/permissions/permission-manager", () => ({
10
+ PermissionManager: {
11
+ check: jest.fn(),
12
+ request: jest.fn(),
13
+ openSettings: jest.fn(),
14
+ },
15
+ }));
16
+
17
+ const mockPermissionManager = PermissionManager as jest.Mocked<
18
+ typeof PermissionManager
19
+ >;
20
+
21
+ // Track the AppState listener
22
+ let appStateCallback: ((state: string) => void) | null = null;
23
+ const mockRemove = jest.fn();
24
+
25
+ // Ensure AppState.currentState is set to a string value
26
+ // (the hook uses appStateRef.current.match() which requires a string)
27
+ (AppState as any).currentState = "active";
28
+
29
+ // Override AppState.addEventListener to capture the callback
30
+ const originalAddEventListener = AppState.addEventListener;
31
+ beforeAll(() => {
32
+ (AppState as any).addEventListener = jest.fn(
33
+ (event: string, callback: any) => {
34
+ if (event === "change") {
35
+ appStateCallback = callback;
36
+ }
37
+ return { remove: mockRemove };
38
+ }
39
+ );
40
+ });
41
+
42
+ afterAll(() => {
43
+ (AppState as any).addEventListener = originalAddEventListener;
44
+ });
45
+
46
+ describe("usePermission", () => {
47
+ beforeEach(() => {
48
+ jest.clearAllMocks();
49
+ appStateCallback = null;
50
+ mockPermissionManager.check.mockResolvedValue({
51
+ status: "undetermined",
52
+ canAskAgain: true,
53
+ });
54
+ mockPermissionManager.request.mockResolvedValue({
55
+ status: "granted",
56
+ canAskAgain: true,
57
+ });
58
+ mockPermissionManager.openSettings.mockResolvedValue();
59
+ });
60
+
61
+ it("should return loading state initially", () => {
62
+ const { result } = renderHook(() => usePermission("camera"));
63
+
64
+ expect(result.current.isLoading).toBe(true);
65
+ expect(result.current.status).toBe("undetermined");
66
+ });
67
+
68
+ it("should return granted status after check", async () => {
69
+ mockPermissionManager.check.mockResolvedValue({
70
+ status: "granted",
71
+ canAskAgain: true,
72
+ });
73
+
74
+ const { result } = renderHook(() => usePermission("camera"));
75
+
76
+ await waitFor(() => {
77
+ expect(result.current.isLoading).toBe(false);
78
+ });
79
+
80
+ expect(result.current.status).toBe("granted");
81
+ expect(result.current.isGranted).toBe(true);
82
+ expect(result.current.isBlocked).toBe(false);
83
+ });
84
+
85
+ it("should return denied status after check", async () => {
86
+ mockPermissionManager.check.mockResolvedValue({
87
+ status: "denied",
88
+ canAskAgain: true,
89
+ });
90
+
91
+ const { result } = renderHook(() => usePermission("camera"));
92
+
93
+ await waitFor(() => {
94
+ expect(result.current.isLoading).toBe(false);
95
+ });
96
+
97
+ expect(result.current.status).toBe("denied");
98
+ expect(result.current.isGranted).toBe(false);
99
+ expect(result.current.isBlocked).toBe(false);
100
+ });
101
+
102
+ it("should return blocked status after check", async () => {
103
+ mockPermissionManager.check.mockResolvedValue({
104
+ status: "blocked",
105
+ canAskAgain: false,
106
+ });
107
+
108
+ const { result } = renderHook(() => usePermission("camera"));
109
+
110
+ await waitFor(() => {
111
+ expect(result.current.isLoading).toBe(false);
112
+ });
113
+
114
+ expect(result.current.status).toBe("blocked");
115
+ expect(result.current.isGranted).toBe(false);
116
+ expect(result.current.isBlocked).toBe(true);
117
+ });
118
+
119
+ it("should request permission and return status", async () => {
120
+ mockPermissionManager.request.mockResolvedValue({
121
+ status: "granted",
122
+ canAskAgain: true,
123
+ });
124
+
125
+ const { result } = renderHook(() => usePermission("camera"));
126
+
127
+ await waitFor(() => {
128
+ expect(result.current.isLoading).toBe(false);
129
+ });
130
+
131
+ let requestResult: string = "";
132
+ await act(async () => {
133
+ requestResult = await result.current.request();
134
+ });
135
+
136
+ expect(requestResult).toBe("granted");
137
+ expect(mockPermissionManager.request).toHaveBeenCalledWith("camera");
138
+ expect(result.current.status).toBe("granted");
139
+ });
140
+
141
+ it("should call PermissionManager.openSettings", async () => {
142
+ const { result } = renderHook(() => usePermission("camera"));
143
+
144
+ await waitFor(() => {
145
+ expect(result.current.isLoading).toBe(false);
146
+ });
147
+
148
+ await act(async () => {
149
+ await result.current.openSettings();
150
+ });
151
+
152
+ expect(mockPermissionManager.openSettings).toHaveBeenCalled();
153
+ });
154
+
155
+ it("should re-check permission when app becomes active", async () => {
156
+ mockPermissionManager.check.mockResolvedValue({
157
+ status: "denied",
158
+ canAskAgain: true,
159
+ });
160
+
161
+ const { result } = renderHook(() => usePermission("camera"));
162
+
163
+ await waitFor(() => {
164
+ expect(result.current.isLoading).toBe(false);
165
+ });
166
+
167
+ expect(result.current.status).toBe("denied");
168
+ expect(mockPermissionManager.check).toHaveBeenCalledTimes(1);
169
+
170
+ // Simulate going to background and coming back
171
+ mockPermissionManager.check.mockResolvedValue({
172
+ status: "granted",
173
+ canAskAgain: true,
174
+ });
175
+
176
+ // Simulate AppState change: go to background, then back to active
177
+ act(() => {
178
+ appStateCallback?.("background");
179
+ });
180
+ act(() => {
181
+ appStateCallback?.("active");
182
+ });
183
+
184
+ await waitFor(() => {
185
+ expect(result.current.status).toBe("granted");
186
+ });
187
+
188
+ expect(mockPermissionManager.check).toHaveBeenCalledTimes(2);
189
+ });
190
+
191
+ it("should handle different permission types", async () => {
192
+ mockPermissionManager.check.mockResolvedValue({
193
+ status: "granted",
194
+ canAskAgain: true,
195
+ });
196
+
197
+ const { result } = renderHook(() => usePermission("location"));
198
+
199
+ await waitFor(() => {
200
+ expect(result.current.isLoading).toBe(false);
201
+ });
202
+
203
+ expect(mockPermissionManager.check).toHaveBeenCalledWith("location");
204
+ expect(result.current.config.title).toBe(
205
+ DEFAULT_PERMISSION_CONFIGS.location.title
206
+ );
207
+ });
208
+
209
+ it("should merge custom config with defaults", async () => {
210
+ const customConfig = {
211
+ title: "Custom Title",
212
+ message: "Custom message for camera access",
213
+ };
214
+
215
+ const { result } = renderHook(() => usePermission("camera", customConfig));
216
+
217
+ await waitFor(() => {
218
+ expect(result.current.isLoading).toBe(false);
219
+ });
220
+
221
+ expect(result.current.config.title).toBe("Custom Title");
222
+ expect(result.current.config.message).toBe(
223
+ "Custom message for camera access"
224
+ );
225
+ // icon should still be from defaults
226
+ expect(result.current.config.icon).toBe(
227
+ DEFAULT_PERMISSION_CONFIGS.camera.icon
228
+ );
229
+ });
230
+ });