@croacroa/react-native-template 1.0.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 (109) hide show
  1. package/.env.example +18 -0
  2. package/.eslintrc.js +55 -0
  3. package/.github/workflows/ci.yml +184 -0
  4. package/.github/workflows/eas-build.yml +55 -0
  5. package/.github/workflows/eas-update.yml +50 -0
  6. package/.gitignore +62 -0
  7. package/.prettierrc +11 -0
  8. package/.storybook/main.ts +28 -0
  9. package/.storybook/preview.tsx +30 -0
  10. package/CHANGELOG.md +106 -0
  11. package/CONTRIBUTING.md +377 -0
  12. package/README.md +399 -0
  13. package/__tests__/components/Button.test.tsx +74 -0
  14. package/__tests__/hooks/useAuth.test.tsx +499 -0
  15. package/__tests__/services/api.test.ts +535 -0
  16. package/__tests__/utils/cn.test.ts +39 -0
  17. package/app/(auth)/_layout.tsx +36 -0
  18. package/app/(auth)/home.tsx +117 -0
  19. package/app/(auth)/profile.tsx +152 -0
  20. package/app/(auth)/settings.tsx +147 -0
  21. package/app/(public)/_layout.tsx +21 -0
  22. package/app/(public)/forgot-password.tsx +127 -0
  23. package/app/(public)/login.tsx +120 -0
  24. package/app/(public)/onboarding.tsx +5 -0
  25. package/app/(public)/register.tsx +139 -0
  26. package/app/_layout.tsx +97 -0
  27. package/app/index.tsx +21 -0
  28. package/app.config.ts +72 -0
  29. package/assets/images/.gitkeep +7 -0
  30. package/assets/images/adaptive-icon.png +0 -0
  31. package/assets/images/favicon.png +0 -0
  32. package/assets/images/icon.png +0 -0
  33. package/assets/images/notification-icon.png +0 -0
  34. package/assets/images/splash.png +0 -0
  35. package/babel.config.js +10 -0
  36. package/components/ErrorBoundary.tsx +169 -0
  37. package/components/forms/FormInput.tsx +78 -0
  38. package/components/forms/index.ts +1 -0
  39. package/components/onboarding/OnboardingScreen.tsx +370 -0
  40. package/components/onboarding/index.ts +2 -0
  41. package/components/ui/AnimatedButton.tsx +156 -0
  42. package/components/ui/AnimatedCard.tsx +108 -0
  43. package/components/ui/Avatar.tsx +316 -0
  44. package/components/ui/Badge.tsx +416 -0
  45. package/components/ui/BottomSheet.tsx +307 -0
  46. package/components/ui/Button.stories.tsx +115 -0
  47. package/components/ui/Button.tsx +104 -0
  48. package/components/ui/Card.stories.tsx +84 -0
  49. package/components/ui/Card.tsx +32 -0
  50. package/components/ui/Checkbox.tsx +261 -0
  51. package/components/ui/Input.stories.tsx +106 -0
  52. package/components/ui/Input.tsx +117 -0
  53. package/components/ui/Modal.tsx +98 -0
  54. package/components/ui/OptimizedImage.tsx +369 -0
  55. package/components/ui/Select.tsx +240 -0
  56. package/components/ui/Skeleton.tsx +180 -0
  57. package/components/ui/index.ts +18 -0
  58. package/constants/config.ts +54 -0
  59. package/docs/adr/001-state-management.md +79 -0
  60. package/docs/adr/002-styling-approach.md +130 -0
  61. package/docs/adr/003-data-fetching.md +155 -0
  62. package/docs/adr/004-auth-adapter-pattern.md +144 -0
  63. package/docs/adr/README.md +78 -0
  64. package/eas.json +47 -0
  65. package/global.css +10 -0
  66. package/hooks/index.ts +25 -0
  67. package/hooks/useApi.ts +236 -0
  68. package/hooks/useAuth.tsx +290 -0
  69. package/hooks/useBiometrics.ts +295 -0
  70. package/hooks/useDeepLinking.ts +256 -0
  71. package/hooks/useNotifications.ts +138 -0
  72. package/hooks/useOffline.ts +69 -0
  73. package/hooks/usePerformance.ts +434 -0
  74. package/hooks/useTheme.tsx +85 -0
  75. package/hooks/useUpdates.ts +358 -0
  76. package/i18n/index.ts +77 -0
  77. package/i18n/locales/en.json +101 -0
  78. package/i18n/locales/fr.json +101 -0
  79. package/jest.config.js +32 -0
  80. package/maestro/README.md +113 -0
  81. package/maestro/config.yaml +35 -0
  82. package/maestro/flows/login.yaml +62 -0
  83. package/maestro/flows/navigation.yaml +68 -0
  84. package/maestro/flows/offline.yaml +60 -0
  85. package/maestro/flows/register.yaml +94 -0
  86. package/metro.config.js +6 -0
  87. package/nativewind-env.d.ts +1 -0
  88. package/package.json +170 -0
  89. package/scripts/init.ps1 +162 -0
  90. package/scripts/init.sh +174 -0
  91. package/services/analytics.ts +428 -0
  92. package/services/api.ts +340 -0
  93. package/services/authAdapter.ts +333 -0
  94. package/services/index.ts +22 -0
  95. package/services/queryClient.ts +97 -0
  96. package/services/sentry.ts +131 -0
  97. package/services/storage.ts +82 -0
  98. package/stores/appStore.ts +54 -0
  99. package/stores/index.ts +2 -0
  100. package/stores/notificationStore.ts +40 -0
  101. package/tailwind.config.js +47 -0
  102. package/tsconfig.json +26 -0
  103. package/types/index.ts +42 -0
  104. package/types/user.ts +63 -0
  105. package/utils/accessibility.ts +446 -0
  106. package/utils/cn.ts +14 -0
  107. package/utils/index.ts +43 -0
  108. package/utils/toast.ts +113 -0
  109. package/utils/validation.ts +67 -0
@@ -0,0 +1,499 @@
1
+ import { renderHook, act, waitFor } from "@testing-library/react-native";
2
+ import * as SecureStore from "expo-secure-store";
3
+ import { router } from "expo-router";
4
+ import { ReactNode } from "react";
5
+
6
+ import { AuthProvider, useAuth, getAuthToken } from "@/hooks/useAuth";
7
+
8
+ // Mock toast
9
+ jest.mock("@/utils/toast", () => ({
10
+ toast: {
11
+ success: jest.fn(),
12
+ error: jest.fn(),
13
+ info: jest.fn(),
14
+ },
15
+ }));
16
+
17
+ const mockSecureStore = SecureStore as jest.Mocked<typeof SecureStore>;
18
+ const mockRouter = router as jest.Mocked<typeof router>;
19
+
20
+ // Helper wrapper for hooks that need AuthProvider
21
+ const wrapper = ({ children }: { children: ReactNode }) => (
22
+ <AuthProvider>{children}</AuthProvider>
23
+ );
24
+
25
+ // Test data
26
+ const mockUser = {
27
+ id: "1",
28
+ email: "test@example.com",
29
+ name: "test",
30
+ };
31
+
32
+ const mockTokens = {
33
+ accessToken: "mock_access_token",
34
+ refreshToken: "mock_refresh_token",
35
+ expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
36
+ };
37
+
38
+ const expiredTokens = {
39
+ accessToken: "expired_access_token",
40
+ refreshToken: "expired_refresh_token",
41
+ expiresAt: Date.now() - 1000, // Already expired
42
+ };
43
+
44
+ describe("useAuth", () => {
45
+ beforeEach(() => {
46
+ jest.clearAllMocks();
47
+
48
+ // Default: no stored auth
49
+ mockSecureStore.getItemAsync.mockResolvedValue(null);
50
+ mockSecureStore.setItemAsync.mockResolvedValue();
51
+ mockSecureStore.deleteItemAsync.mockResolvedValue();
52
+ });
53
+
54
+ describe("initialization", () => {
55
+ it("should start with loading state and then finish loading", async () => {
56
+ const { result } = renderHook(() => useAuth(), { wrapper });
57
+
58
+ // Initially loading
59
+ expect(result.current.isLoading).toBe(true);
60
+
61
+ await waitFor(() => {
62
+ expect(result.current.isLoading).toBe(false);
63
+ });
64
+
65
+ expect(result.current.isAuthenticated).toBe(false);
66
+ expect(result.current.user).toBeNull();
67
+ });
68
+
69
+ it("should load stored auth on mount", async () => {
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));
73
+ return Promise.resolve(null);
74
+ });
75
+
76
+ const { result } = renderHook(() => useAuth(), { wrapper });
77
+
78
+ await waitFor(() => {
79
+ expect(result.current.isLoading).toBe(false);
80
+ });
81
+
82
+ expect(result.current.isAuthenticated).toBe(true);
83
+ expect(result.current.user).toEqual(mockUser);
84
+ });
85
+
86
+ it("should attempt refresh if stored tokens are expired", async () => {
87
+ 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
+ return Promise.resolve(null);
91
+ });
92
+
93
+ const { result } = renderHook(() => useAuth(), { wrapper });
94
+
95
+ await waitFor(() => {
96
+ expect(result.current.isLoading).toBe(false);
97
+ });
98
+
99
+ // The mock implementation in useAuth always succeeds on refresh
100
+ // So user should be authenticated with refreshed tokens
101
+ expect(result.current.isAuthenticated).toBe(true);
102
+ });
103
+
104
+ it("should handle storage errors gracefully", async () => {
105
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
106
+
107
+ mockSecureStore.getItemAsync.mockRejectedValue(new Error("Storage error"));
108
+
109
+ const { result } = renderHook(() => useAuth(), { wrapper });
110
+
111
+ await waitFor(() => {
112
+ expect(result.current.isLoading).toBe(false);
113
+ });
114
+
115
+ expect(result.current.isAuthenticated).toBe(false);
116
+ expect(consoleSpy).toHaveBeenCalled();
117
+
118
+ consoleSpy.mockRestore();
119
+ });
120
+ });
121
+
122
+ describe("signIn", () => {
123
+ it("should sign in successfully with valid credentials", async () => {
124
+ const { result } = renderHook(() => useAuth(), { wrapper });
125
+
126
+ await waitFor(() => {
127
+ expect(result.current.isLoading).toBe(false);
128
+ });
129
+
130
+ await act(async () => {
131
+ await result.current.signIn("test@example.com", "password123");
132
+ });
133
+
134
+ expect(result.current.isAuthenticated).toBe(true);
135
+ expect(result.current.user?.email).toBe("test@example.com");
136
+ expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith(
137
+ "auth_tokens",
138
+ expect.any(String)
139
+ );
140
+ expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith(
141
+ "auth_user",
142
+ expect.any(String)
143
+ );
144
+ });
145
+
146
+ it("should fail sign in with invalid credentials", async () => {
147
+ const { result } = renderHook(() => useAuth(), { wrapper });
148
+
149
+ await waitFor(() => {
150
+ expect(result.current.isLoading).toBe(false);
151
+ });
152
+
153
+ await act(async () => {
154
+ try {
155
+ await result.current.signIn("invalid", "password");
156
+ } catch (error) {
157
+ expect(error).toBeDefined();
158
+ }
159
+ });
160
+
161
+ expect(result.current.isAuthenticated).toBe(false);
162
+ });
163
+
164
+ it("should show success toast on successful sign in", async () => {
165
+ const { toast } = require("@/utils/toast");
166
+ const { result } = renderHook(() => useAuth(), { wrapper });
167
+
168
+ await waitFor(() => {
169
+ expect(result.current.isLoading).toBe(false);
170
+ });
171
+
172
+ await act(async () => {
173
+ await result.current.signIn("test@example.com", "password123");
174
+ });
175
+
176
+ expect(toast.success).toHaveBeenCalledWith("Welcome back!", expect.any(String));
177
+ });
178
+
179
+ it("should show error toast on failed sign in", async () => {
180
+ const { toast } = require("@/utils/toast");
181
+ const { result } = renderHook(() => useAuth(), { wrapper });
182
+
183
+ await waitFor(() => {
184
+ expect(result.current.isLoading).toBe(false);
185
+ });
186
+
187
+ await act(async () => {
188
+ try {
189
+ await result.current.signIn("invalid", "password");
190
+ } catch {
191
+ // Expected to throw
192
+ }
193
+ });
194
+
195
+ expect(toast.error).toHaveBeenCalledWith("Sign in failed", expect.any(String));
196
+ });
197
+ });
198
+
199
+ describe("signUp", () => {
200
+ it("should sign up successfully", async () => {
201
+ const { result } = renderHook(() => useAuth(), { wrapper });
202
+
203
+ await waitFor(() => {
204
+ expect(result.current.isLoading).toBe(false);
205
+ });
206
+
207
+ await act(async () => {
208
+ await result.current.signUp("newuser@example.com", "password123", "New User");
209
+ });
210
+
211
+ expect(result.current.isAuthenticated).toBe(true);
212
+ expect(result.current.user?.name).toBe("New User");
213
+ expect(result.current.user?.email).toBe("newuser@example.com");
214
+ });
215
+
216
+ it("should show success toast on sign up", async () => {
217
+ const { toast } = require("@/utils/toast");
218
+ const { result } = renderHook(() => useAuth(), { wrapper });
219
+
220
+ await waitFor(() => {
221
+ expect(result.current.isLoading).toBe(false);
222
+ });
223
+
224
+ await act(async () => {
225
+ await result.current.signUp("new@example.com", "password", "Test");
226
+ });
227
+
228
+ expect(toast.success).toHaveBeenCalledWith("Account created!", expect.any(String));
229
+ });
230
+ });
231
+
232
+ describe("signOut", () => {
233
+ it("should sign out and clear storage", async () => {
234
+ // Start with authenticated state
235
+ 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));
238
+ return Promise.resolve(null);
239
+ });
240
+
241
+ const { result } = renderHook(() => useAuth(), { wrapper });
242
+
243
+ await waitFor(() => {
244
+ expect(result.current.isAuthenticated).toBe(true);
245
+ });
246
+
247
+ await act(async () => {
248
+ await result.current.signOut();
249
+ });
250
+
251
+ expect(result.current.isAuthenticated).toBe(false);
252
+ expect(result.current.user).toBeNull();
253
+ expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_tokens");
254
+ expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_user");
255
+ });
256
+
257
+ it("should redirect to login after sign out", async () => {
258
+ 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));
261
+ return Promise.resolve(null);
262
+ });
263
+
264
+ const { result } = renderHook(() => useAuth(), { wrapper });
265
+
266
+ await waitFor(() => {
267
+ expect(result.current.isAuthenticated).toBe(true);
268
+ });
269
+
270
+ await act(async () => {
271
+ await result.current.signOut();
272
+ });
273
+
274
+ expect(mockRouter.replace).toHaveBeenCalledWith("/(public)/login");
275
+ });
276
+
277
+ it("should show info toast on sign out", async () => {
278
+ const { toast } = require("@/utils/toast");
279
+
280
+ 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));
283
+ return Promise.resolve(null);
284
+ });
285
+
286
+ const { result } = renderHook(() => useAuth(), { wrapper });
287
+
288
+ await waitFor(() => {
289
+ expect(result.current.isAuthenticated).toBe(true);
290
+ });
291
+
292
+ await act(async () => {
293
+ await result.current.signOut();
294
+ });
295
+
296
+ expect(toast.info).toHaveBeenCalledWith("Signed out");
297
+ });
298
+ });
299
+
300
+ describe("updateUser", () => {
301
+ it("should update user data", async () => {
302
+ 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));
305
+ return Promise.resolve(null);
306
+ });
307
+
308
+ const { result } = renderHook(() => useAuth(), { wrapper });
309
+
310
+ await waitFor(() => {
311
+ expect(result.current.isAuthenticated).toBe(true);
312
+ });
313
+
314
+ act(() => {
315
+ result.current.updateUser({ name: "Updated Name" });
316
+ });
317
+
318
+ expect(result.current.user?.name).toBe("Updated Name");
319
+ expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith(
320
+ "auth_user",
321
+ expect.stringContaining("Updated Name")
322
+ );
323
+ });
324
+
325
+ it("should not update if no user is logged in", async () => {
326
+ const { result } = renderHook(() => useAuth(), { wrapper });
327
+
328
+ await waitFor(() => {
329
+ expect(result.current.isLoading).toBe(false);
330
+ });
331
+
332
+ const setItemCallsBefore = mockSecureStore.setItemAsync.mock.calls.length;
333
+
334
+ act(() => {
335
+ result.current.updateUser({ name: "New Name" });
336
+ });
337
+
338
+ expect(result.current.user).toBeNull();
339
+ // setItemAsync should not have been called for user update
340
+ expect(mockSecureStore.setItemAsync.mock.calls.length).toBe(setItemCallsBefore);
341
+ });
342
+ });
343
+
344
+ describe("refreshSession", () => {
345
+ it("should refresh session when tokens exist", async () => {
346
+ 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));
349
+ return Promise.resolve(null);
350
+ });
351
+
352
+ const { result } = renderHook(() => useAuth(), { wrapper });
353
+
354
+ await waitFor(() => {
355
+ expect(result.current.isAuthenticated).toBe(true);
356
+ });
357
+
358
+ let refreshResult: boolean = false;
359
+ await act(async () => {
360
+ refreshResult = await result.current.refreshSession();
361
+ });
362
+
363
+ expect(refreshResult).toBe(true);
364
+ });
365
+
366
+ it("should return false if no tokens exist", async () => {
367
+ const { result } = renderHook(() => useAuth(), { wrapper });
368
+
369
+ await waitFor(() => {
370
+ expect(result.current.isLoading).toBe(false);
371
+ });
372
+
373
+ let refreshResult: boolean = true;
374
+ await act(async () => {
375
+ refreshResult = await result.current.refreshSession();
376
+ });
377
+
378
+ expect(refreshResult).toBe(false);
379
+ });
380
+ });
381
+
382
+ describe("token expiration handling", () => {
383
+ beforeEach(() => {
384
+ jest.useFakeTimers();
385
+ });
386
+
387
+ afterEach(() => {
388
+ jest.useRealTimers();
389
+ });
390
+
391
+ it("should setup refresh interval when tokens exist", async () => {
392
+ 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));
395
+ return Promise.resolve(null);
396
+ });
397
+
398
+ const { result } = renderHook(() => useAuth(), { wrapper });
399
+
400
+ await waitFor(() => {
401
+ expect(result.current.isAuthenticated).toBe(true);
402
+ });
403
+
404
+ // Token should still be valid
405
+ expect(result.current.isAuthenticated).toBe(true);
406
+ });
407
+
408
+ it("should trigger refresh when token is about to expire", async () => {
409
+ const soonToExpireTokens = {
410
+ ...mockTokens,
411
+ expiresAt: Date.now() + 4 * 60 * 1000, // 4 minutes (< 5 min threshold)
412
+ };
413
+
414
+ 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));
417
+ return Promise.resolve(null);
418
+ });
419
+
420
+ const { result } = renderHook(() => useAuth(), { wrapper });
421
+
422
+ await waitFor(() => {
423
+ expect(result.current.isAuthenticated).toBe(true);
424
+ });
425
+
426
+ // The useEffect should have triggered a refresh
427
+ // Verify by checking setItemAsync was called (tokens saved after refresh)
428
+ await waitFor(() => {
429
+ expect(mockSecureStore.setItemAsync).toHaveBeenCalled();
430
+ });
431
+ });
432
+ });
433
+
434
+ describe("error handling", () => {
435
+ it("should throw error when useAuth is used outside AuthProvider", () => {
436
+ // Suppress console.error for this test
437
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
438
+
439
+ expect(() => {
440
+ renderHook(() => useAuth());
441
+ }).toThrow("useAuth must be used within an AuthProvider");
442
+
443
+ consoleSpy.mockRestore();
444
+ });
445
+ });
446
+ });
447
+
448
+ describe("getAuthToken", () => {
449
+ beforeEach(() => {
450
+ jest.clearAllMocks();
451
+ (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
452
+ });
453
+
454
+ it("should return null when no tokens stored", async () => {
455
+ const token = await getAuthToken();
456
+ expect(token).toBeNull();
457
+ });
458
+
459
+ it("should return access token when tokens exist", async () => {
460
+ (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(
461
+ JSON.stringify(mockTokens)
462
+ );
463
+
464
+ const token = await getAuthToken();
465
+ expect(token).toBe(mockTokens.accessToken);
466
+ });
467
+
468
+ it("should warn when token is expired", async () => {
469
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
470
+
471
+ (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(
472
+ JSON.stringify(expiredTokens)
473
+ );
474
+
475
+ const token = await getAuthToken();
476
+
477
+ expect(token).toBe(expiredTokens.accessToken);
478
+ expect(consoleSpy).toHaveBeenCalledWith(
479
+ "Token is expired or about to expire"
480
+ );
481
+
482
+ consoleSpy.mockRestore();
483
+ });
484
+
485
+ it("should handle storage errors gracefully", async () => {
486
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
487
+
488
+ (SecureStore.getItemAsync as jest.Mock).mockRejectedValue(
489
+ new Error("Storage error")
490
+ );
491
+
492
+ const token = await getAuthToken();
493
+
494
+ expect(token).toBeNull();
495
+ expect(consoleSpy).toHaveBeenCalled();
496
+
497
+ consoleSpy.mockRestore();
498
+ });
499
+ });