@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,535 @@
1
+ import * as SecureStore from "expo-secure-store";
2
+ import { router } from "expo-router";
3
+
4
+ import { ApiClient, getTokens, saveTokens, getValidAccessToken } from "@/services/api";
5
+
6
+ // Mock dependencies
7
+ jest.mock("@/utils/toast", () => ({
8
+ toast: {
9
+ success: jest.fn(),
10
+ error: jest.fn(),
11
+ info: jest.fn(),
12
+ },
13
+ }));
14
+
15
+ jest.mock("@/constants/config", () => ({
16
+ API_URL: "https://api.example.com",
17
+ }));
18
+
19
+ const mockSecureStore = SecureStore as jest.Mocked<typeof SecureStore>;
20
+ const mockRouter = router as jest.Mocked<typeof router>;
21
+
22
+ // Test data
23
+ const mockTokens = {
24
+ accessToken: "valid_access_token",
25
+ refreshToken: "valid_refresh_token",
26
+ expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
27
+ };
28
+
29
+ const expiredTokens = {
30
+ accessToken: "expired_access_token",
31
+ refreshToken: "expired_refresh_token",
32
+ expiresAt: Date.now() - 1000, // Already expired
33
+ };
34
+
35
+ const soonToExpireTokens = {
36
+ accessToken: "soon_to_expire_token",
37
+ refreshToken: "valid_refresh_token",
38
+ expiresAt: Date.now() + 2 * 60 * 1000, // 2 minutes (< 5 min threshold)
39
+ };
40
+
41
+ describe("ApiClient", () => {
42
+ let apiClient: ApiClient;
43
+ let fetchMock: jest.SpyInstance;
44
+
45
+ beforeEach(() => {
46
+ jest.clearAllMocks();
47
+ apiClient = new ApiClient("https://api.example.com", 5000);
48
+
49
+ // Default: valid tokens stored
50
+ mockSecureStore.getItemAsync.mockResolvedValue(JSON.stringify(mockTokens));
51
+ mockSecureStore.setItemAsync.mockResolvedValue();
52
+ mockSecureStore.deleteItemAsync.mockResolvedValue();
53
+
54
+ // Mock fetch
55
+ fetchMock = jest.spyOn(globalThis, "fetch");
56
+ });
57
+
58
+ afterEach(() => {
59
+ fetchMock.mockRestore();
60
+ });
61
+
62
+ describe("HTTP Methods", () => {
63
+ it("should make GET request with auth header", async () => {
64
+ fetchMock.mockResolvedValueOnce({
65
+ ok: true,
66
+ status: 200,
67
+ text: () => Promise.resolve(JSON.stringify({ data: "test" })),
68
+ });
69
+
70
+ const result = await apiClient.get("/users");
71
+
72
+ expect(fetchMock).toHaveBeenCalledWith(
73
+ "https://api.example.com/users",
74
+ expect.objectContaining({
75
+ method: "GET",
76
+ headers: expect.objectContaining({
77
+ Authorization: `Bearer ${mockTokens.accessToken}`,
78
+ }),
79
+ })
80
+ );
81
+ expect(result).toEqual({ data: "test" });
82
+ });
83
+
84
+ it("should make POST request with body", async () => {
85
+ fetchMock.mockResolvedValueOnce({
86
+ ok: true,
87
+ status: 201,
88
+ text: () => Promise.resolve(JSON.stringify({ id: 1 })),
89
+ });
90
+
91
+ const result = await apiClient.post("/users", { name: "John" });
92
+
93
+ expect(fetchMock).toHaveBeenCalledWith(
94
+ "https://api.example.com/users",
95
+ expect.objectContaining({
96
+ method: "POST",
97
+ body: JSON.stringify({ name: "John" }),
98
+ })
99
+ );
100
+ expect(result).toEqual({ id: 1 });
101
+ });
102
+
103
+ it("should make PUT request", async () => {
104
+ fetchMock.mockResolvedValueOnce({
105
+ ok: true,
106
+ status: 200,
107
+ text: () => Promise.resolve(JSON.stringify({ updated: true })),
108
+ });
109
+
110
+ await apiClient.put("/users/1", { name: "Jane" });
111
+
112
+ expect(fetchMock).toHaveBeenCalledWith(
113
+ "https://api.example.com/users/1",
114
+ expect.objectContaining({
115
+ method: "PUT",
116
+ body: JSON.stringify({ name: "Jane" }),
117
+ })
118
+ );
119
+ });
120
+
121
+ it("should make PATCH request", async () => {
122
+ fetchMock.mockResolvedValueOnce({
123
+ ok: true,
124
+ status: 200,
125
+ text: () => Promise.resolve(JSON.stringify({ patched: true })),
126
+ });
127
+
128
+ await apiClient.patch("/users/1", { status: "active" });
129
+
130
+ expect(fetchMock).toHaveBeenCalledWith(
131
+ "https://api.example.com/users/1",
132
+ expect.objectContaining({
133
+ method: "PATCH",
134
+ })
135
+ );
136
+ });
137
+
138
+ it("should make DELETE request", async () => {
139
+ fetchMock.mockResolvedValueOnce({
140
+ ok: true,
141
+ status: 204,
142
+ text: () => Promise.resolve(""),
143
+ });
144
+
145
+ await apiClient.delete("/users/1");
146
+
147
+ expect(fetchMock).toHaveBeenCalledWith(
148
+ "https://api.example.com/users/1",
149
+ expect.objectContaining({
150
+ method: "DELETE",
151
+ })
152
+ );
153
+ });
154
+
155
+ it("should skip auth header when requiresAuth is false", async () => {
156
+ fetchMock.mockResolvedValueOnce({
157
+ ok: true,
158
+ status: 200,
159
+ text: () => Promise.resolve(JSON.stringify({ public: true })),
160
+ });
161
+
162
+ await apiClient.get("/public", { requiresAuth: false });
163
+
164
+ expect(fetchMock).toHaveBeenCalledWith(
165
+ "https://api.example.com/public",
166
+ expect.objectContaining({
167
+ headers: expect.not.objectContaining({
168
+ Authorization: expect.any(String),
169
+ }),
170
+ })
171
+ );
172
+ });
173
+ });
174
+
175
+ describe("401 Handling and Token Refresh", () => {
176
+ it("should refresh token and retry on 401", async () => {
177
+ const newAccessToken = "new_access_token";
178
+
179
+ // Track stored tokens to simulate real storage behavior
180
+ let storedTokens = JSON.stringify(mockTokens);
181
+ mockSecureStore.getItemAsync.mockImplementation(() =>
182
+ Promise.resolve(storedTokens)
183
+ );
184
+ mockSecureStore.setItemAsync.mockImplementation((_, value) => {
185
+ storedTokens = value;
186
+ return Promise.resolve();
187
+ });
188
+
189
+ // First call returns 401
190
+ fetchMock.mockResolvedValueOnce({
191
+ ok: false,
192
+ status: 401,
193
+ statusText: "Unauthorized",
194
+ });
195
+
196
+ // Refresh token call succeeds
197
+ fetchMock.mockResolvedValueOnce({
198
+ ok: true,
199
+ status: 200,
200
+ json: () =>
201
+ Promise.resolve({
202
+ accessToken: newAccessToken,
203
+ refreshToken: "new_refresh_token",
204
+ expiresIn: 3600,
205
+ }),
206
+ });
207
+
208
+ // Retry call succeeds
209
+ fetchMock.mockResolvedValueOnce({
210
+ ok: true,
211
+ status: 200,
212
+ text: () => Promise.resolve(JSON.stringify({ success: true })),
213
+ });
214
+
215
+ const result = await apiClient.get("/protected");
216
+
217
+ // Should have made 3 calls: original, refresh, retry
218
+ expect(fetchMock).toHaveBeenCalledTimes(3);
219
+
220
+ // Verify refresh was called
221
+ expect(fetchMock).toHaveBeenNthCalledWith(
222
+ 2,
223
+ "https://api.example.com/auth/refresh",
224
+ expect.objectContaining({
225
+ method: "POST",
226
+ body: JSON.stringify({ refreshToken: mockTokens.refreshToken }),
227
+ })
228
+ );
229
+
230
+ // Verify retry was made with new token
231
+ expect(fetchMock).toHaveBeenNthCalledWith(
232
+ 3,
233
+ "https://api.example.com/protected",
234
+ expect.objectContaining({
235
+ headers: expect.objectContaining({
236
+ Authorization: `Bearer ${newAccessToken}`,
237
+ }),
238
+ })
239
+ );
240
+
241
+ expect(result).toEqual({ success: true });
242
+ });
243
+
244
+ it("should redirect to login when refresh fails", async () => {
245
+ const { toast } = require("@/utils/toast");
246
+
247
+ // First call returns 401
248
+ fetchMock.mockResolvedValueOnce({
249
+ ok: false,
250
+ status: 401,
251
+ statusText: "Unauthorized",
252
+ });
253
+
254
+ // Refresh token call fails
255
+ fetchMock.mockResolvedValueOnce({
256
+ ok: false,
257
+ status: 401,
258
+ statusText: "Unauthorized",
259
+ });
260
+
261
+ await expect(apiClient.get("/protected")).rejects.toThrow(
262
+ "Authentication failed"
263
+ );
264
+
265
+ expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_tokens");
266
+ expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_user");
267
+ expect(toast.error).toHaveBeenCalledWith(
268
+ "Session expired",
269
+ "Please sign in again"
270
+ );
271
+ expect(mockRouter.replace).toHaveBeenCalledWith("/(public)/login");
272
+ });
273
+
274
+ it("should not retry more than once on 401", async () => {
275
+ // First call returns 401
276
+ fetchMock.mockResolvedValueOnce({
277
+ ok: false,
278
+ status: 401,
279
+ statusText: "Unauthorized",
280
+ });
281
+
282
+ // Refresh succeeds
283
+ fetchMock.mockResolvedValueOnce({
284
+ ok: true,
285
+ status: 200,
286
+ json: () =>
287
+ Promise.resolve({
288
+ accessToken: "new_token",
289
+ expiresIn: 3600,
290
+ }),
291
+ });
292
+
293
+ // Retry also returns 401
294
+ fetchMock.mockResolvedValueOnce({
295
+ ok: false,
296
+ status: 401,
297
+ statusText: "Unauthorized",
298
+ });
299
+
300
+ await expect(apiClient.get("/protected")).rejects.toThrow();
301
+
302
+ // Should only retry once (3 total calls: original, refresh, retry)
303
+ expect(fetchMock).toHaveBeenCalledTimes(3);
304
+ });
305
+
306
+ it("should handle concurrent 401s with single refresh", async () => {
307
+ const newAccessToken = "new_access_token";
308
+
309
+ // Setup: both initial requests return 401
310
+ fetchMock
311
+ .mockResolvedValueOnce({
312
+ ok: false,
313
+ status: 401,
314
+ statusText: "Unauthorized",
315
+ })
316
+ .mockResolvedValueOnce({
317
+ ok: false,
318
+ status: 401,
319
+ statusText: "Unauthorized",
320
+ })
321
+ // Single refresh call
322
+ .mockResolvedValueOnce({
323
+ ok: true,
324
+ status: 200,
325
+ json: () =>
326
+ Promise.resolve({
327
+ accessToken: newAccessToken,
328
+ refreshToken: "new_refresh",
329
+ expiresIn: 3600,
330
+ }),
331
+ })
332
+ // Retries succeed
333
+ .mockResolvedValueOnce({
334
+ ok: true,
335
+ status: 200,
336
+ text: () => Promise.resolve(JSON.stringify({ id: 1 })),
337
+ })
338
+ .mockResolvedValueOnce({
339
+ ok: true,
340
+ status: 200,
341
+ text: () => Promise.resolve(JSON.stringify({ id: 2 })),
342
+ });
343
+
344
+ // Make concurrent requests
345
+ const [result1, result2] = await Promise.all([
346
+ apiClient.get("/endpoint1"),
347
+ apiClient.get("/endpoint2"),
348
+ ]);
349
+
350
+ expect(result1).toEqual({ id: 1 });
351
+ expect(result2).toEqual({ id: 2 });
352
+
353
+ // Verify only one refresh call was made
354
+ const refreshCalls = fetchMock.mock.calls.filter(
355
+ (call) => call[0] === "https://api.example.com/auth/refresh"
356
+ );
357
+ expect(refreshCalls.length).toBe(1);
358
+ });
359
+ });
360
+
361
+ describe("Proactive Token Refresh", () => {
362
+ it("should refresh token proactively when about to expire", async () => {
363
+ mockSecureStore.getItemAsync.mockResolvedValue(
364
+ JSON.stringify(soonToExpireTokens)
365
+ );
366
+
367
+ // Refresh call
368
+ fetchMock.mockResolvedValueOnce({
369
+ ok: true,
370
+ status: 200,
371
+ json: () =>
372
+ Promise.resolve({
373
+ accessToken: "fresh_token",
374
+ expiresIn: 3600,
375
+ }),
376
+ });
377
+
378
+ // Actual request
379
+ fetchMock.mockResolvedValueOnce({
380
+ ok: true,
381
+ status: 200,
382
+ text: () => Promise.resolve(JSON.stringify({ data: "test" })),
383
+ });
384
+
385
+ await apiClient.get("/data");
386
+
387
+ // Should have called refresh before the actual request
388
+ expect(fetchMock).toHaveBeenNthCalledWith(
389
+ 1,
390
+ "https://api.example.com/auth/refresh",
391
+ expect.any(Object)
392
+ );
393
+ });
394
+ });
395
+
396
+ describe("Error Handling", () => {
397
+ it("should throw ApiError with status for HTTP errors", async () => {
398
+ fetchMock.mockResolvedValueOnce({
399
+ ok: false,
400
+ status: 404,
401
+ statusText: "Not Found",
402
+ json: () => Promise.resolve({ message: "Resource not found" }),
403
+ });
404
+
405
+ try {
406
+ await apiClient.get("/nonexistent", { requiresAuth: false });
407
+ fail("Should have thrown");
408
+ } catch (error: any) {
409
+ expect(error.message).toContain("404");
410
+ expect(error.status).toBe(404);
411
+ expect(error.data).toEqual({ message: "Resource not found" });
412
+ }
413
+ });
414
+
415
+ it("should handle timeout errors", async () => {
416
+ // Create abort error
417
+ const abortError = new Error("Aborted");
418
+ abortError.name = "AbortError";
419
+ fetchMock.mockRejectedValueOnce(abortError);
420
+
421
+ try {
422
+ await apiClient.get("/slow", { requiresAuth: false });
423
+ fail("Should have thrown");
424
+ } catch (error: any) {
425
+ expect(error.message).toBe("Request timeout");
426
+ expect(error.status).toBe(408);
427
+ }
428
+ });
429
+
430
+ it("should handle network errors", async () => {
431
+ fetchMock.mockRejectedValueOnce(new Error("Network request failed"));
432
+
433
+ try {
434
+ await apiClient.get("/data", { requiresAuth: false });
435
+ fail("Should have thrown");
436
+ } catch (error: any) {
437
+ expect(error.message).toBe("Network error");
438
+ expect(error.status).toBe(0);
439
+ }
440
+ });
441
+
442
+ it("should handle empty response body", async () => {
443
+ fetchMock.mockResolvedValueOnce({
444
+ ok: true,
445
+ status: 204,
446
+ text: () => Promise.resolve(""),
447
+ });
448
+
449
+ const result = await apiClient.delete("/users/1", { requiresAuth: false });
450
+
451
+ expect(result).toEqual({});
452
+ });
453
+ });
454
+
455
+ describe("Custom Headers", () => {
456
+ it("should merge custom headers with defaults", async () => {
457
+ fetchMock.mockResolvedValueOnce({
458
+ ok: true,
459
+ status: 200,
460
+ text: () => Promise.resolve(JSON.stringify({})),
461
+ });
462
+
463
+ await apiClient.get("/data", {
464
+ headers: { "X-Custom-Header": "custom-value" },
465
+ });
466
+
467
+ expect(fetchMock).toHaveBeenCalledWith(
468
+ expect.any(String),
469
+ expect.objectContaining({
470
+ headers: expect.objectContaining({
471
+ "Content-Type": "application/json",
472
+ "X-Custom-Header": "custom-value",
473
+ Authorization: expect.any(String),
474
+ }),
475
+ })
476
+ );
477
+ });
478
+ });
479
+ });
480
+
481
+ describe("Token Utilities", () => {
482
+ beforeEach(() => {
483
+ jest.clearAllMocks();
484
+ mockSecureStore.getItemAsync.mockResolvedValue(null);
485
+ });
486
+
487
+ describe("getTokens", () => {
488
+ it("should return null when no tokens stored", async () => {
489
+ const tokens = await getTokens();
490
+ expect(tokens).toBeNull();
491
+ });
492
+
493
+ it("should return parsed tokens when stored", async () => {
494
+ mockSecureStore.getItemAsync.mockResolvedValue(JSON.stringify(mockTokens));
495
+
496
+ const tokens = await getTokens();
497
+
498
+ expect(tokens).toEqual(mockTokens);
499
+ });
500
+
501
+ it("should return null on parse error", async () => {
502
+ mockSecureStore.getItemAsync.mockResolvedValue("invalid json{");
503
+
504
+ const tokens = await getTokens();
505
+
506
+ expect(tokens).toBeNull();
507
+ });
508
+ });
509
+
510
+ describe("saveTokens", () => {
511
+ it("should save tokens to secure store", async () => {
512
+ await saveTokens(mockTokens);
513
+
514
+ expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith(
515
+ "auth_tokens",
516
+ JSON.stringify(mockTokens)
517
+ );
518
+ });
519
+ });
520
+
521
+ describe("getValidAccessToken", () => {
522
+ it("should return null when no tokens", async () => {
523
+ const token = await getValidAccessToken();
524
+ expect(token).toBeNull();
525
+ });
526
+
527
+ it("should return token when not expired", async () => {
528
+ mockSecureStore.getItemAsync.mockResolvedValue(JSON.stringify(mockTokens));
529
+
530
+ const token = await getValidAccessToken();
531
+
532
+ expect(token).toBe(mockTokens.accessToken);
533
+ });
534
+ });
535
+ });
@@ -0,0 +1,39 @@
1
+ import { cn } from "@/utils/cn";
2
+
3
+ describe("cn utility", () => {
4
+ it("merges class names correctly", () => {
5
+ const result = cn("px-4", "py-2");
6
+ expect(result).toBe("px-4 py-2");
7
+ });
8
+
9
+ it("handles conditional classes", () => {
10
+ const isActive = true;
11
+ const result = cn("base-class", isActive && "active-class");
12
+ expect(result).toBe("base-class active-class");
13
+ });
14
+
15
+ it("filters out falsy values", () => {
16
+ const result = cn("base", false && "hidden", null, undefined, "visible");
17
+ expect(result).toBe("base visible");
18
+ });
19
+
20
+ it("handles array of classes", () => {
21
+ const result = cn(["class1", "class2"], "class3");
22
+ expect(result).toBe("class1 class2 class3");
23
+ });
24
+
25
+ it("merges conflicting Tailwind classes correctly", () => {
26
+ // tailwind-merge should keep the last conflicting class
27
+ const result = cn("px-4", "px-6");
28
+ expect(result).toBe("px-6");
29
+ });
30
+
31
+ it("handles object syntax", () => {
32
+ const result = cn({
33
+ "class-a": true,
34
+ "class-b": false,
35
+ "class-c": true,
36
+ });
37
+ expect(result).toBe("class-a class-c");
38
+ });
39
+ });
@@ -0,0 +1,36 @@
1
+ import { Redirect, Stack } from "expo-router";
2
+ import { useAuth } from "@/hooks/useAuth";
3
+ import { useTheme } from "@/hooks/useTheme";
4
+ import { View, ActivityIndicator } from "react-native";
5
+
6
+ export default function AuthLayout() {
7
+ const { isAuthenticated, isLoading } = useAuth();
8
+ const { isDark } = useTheme();
9
+
10
+ if (isLoading) {
11
+ return (
12
+ <View className="flex-1 items-center justify-center bg-background-light dark:bg-background-dark">
13
+ <ActivityIndicator size="large" color="#3b82f6" />
14
+ </View>
15
+ );
16
+ }
17
+
18
+ if (!isAuthenticated) {
19
+ return <Redirect href="/(public)/login" />;
20
+ }
21
+
22
+ return (
23
+ <Stack
24
+ screenOptions={{
25
+ headerShown: false,
26
+ contentStyle: {
27
+ backgroundColor: isDark ? "#0f172a" : "#ffffff",
28
+ },
29
+ }}
30
+ >
31
+ <Stack.Screen name="home" />
32
+ <Stack.Screen name="profile" />
33
+ <Stack.Screen name="settings" />
34
+ </Stack>
35
+ );
36
+ }