@croacroa/react-native-template 2.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 -21
  10. package/README.md +446 -402
  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 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  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 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -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 -375
  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 -176
  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,329 @@
1
+ import { renderHook, act } from "@testing-library/react-native";
2
+
3
+ import { useWebSocket } from "@/hooks/useWebSocket";
4
+ import { useChannel } from "@/hooks/useChannel";
5
+ import { usePresence } from "@/hooks/usePresence";
6
+ import { WebSocketManager } from "@/services/realtime/websocket-manager";
7
+
8
+ // Mock WebSocketManager
9
+ jest.mock("@/services/realtime/websocket-manager", () => {
10
+ const mockConnect = jest.fn().mockResolvedValue(undefined);
11
+ const mockDisconnect = jest.fn();
12
+ const mockSend = jest.fn();
13
+ const mockSubscribe = jest.fn().mockReturnValue(jest.fn());
14
+ const mockOnStatusChange = jest.fn().mockReturnValue(jest.fn());
15
+ const mockOnMessage = jest.fn().mockReturnValue(jest.fn());
16
+ const mockGetStatus = jest.fn().mockReturnValue("disconnected");
17
+
18
+ return {
19
+ WebSocketManager: jest.fn().mockImplementation(() => ({
20
+ connect: mockConnect,
21
+ disconnect: mockDisconnect,
22
+ send: mockSend,
23
+ subscribe: mockSubscribe,
24
+ onStatusChange: mockOnStatusChange,
25
+ onMessage: mockOnMessage,
26
+ getStatus: mockGetStatus,
27
+ })),
28
+ };
29
+ });
30
+
31
+ const MockWebSocketManager = WebSocketManager as jest.MockedClass<
32
+ typeof WebSocketManager
33
+ >;
34
+
35
+ describe("useWebSocket", () => {
36
+ beforeEach(() => {
37
+ jest.clearAllMocks();
38
+ MockWebSocketManager.mockClear();
39
+ });
40
+
41
+ it("should create WebSocketManager on mount", () => {
42
+ const config = { url: "wss://api.example.com/ws" };
43
+ renderHook(() => useWebSocket(config));
44
+
45
+ expect(MockWebSocketManager).toHaveBeenCalledWith(config);
46
+ });
47
+
48
+ it("should connect on mount and register status listener", () => {
49
+ const config = { url: "wss://api.example.com/ws" };
50
+ renderHook(() => useWebSocket(config));
51
+
52
+ const instance = MockWebSocketManager.mock.results[0].value;
53
+ expect(instance.onStatusChange).toHaveBeenCalled();
54
+ expect(instance.connect).toHaveBeenCalled();
55
+ });
56
+
57
+ it("should call manager.connect when connect is called", async () => {
58
+ const config = { url: "wss://api.example.com/ws" };
59
+ const { result } = renderHook(() => useWebSocket(config));
60
+
61
+ const instance = MockWebSocketManager.mock.results[0].value;
62
+ instance.connect.mockClear();
63
+
64
+ await act(async () => {
65
+ await result.current.connect();
66
+ });
67
+
68
+ expect(instance.connect).toHaveBeenCalled();
69
+ });
70
+
71
+ it("should call manager.disconnect when disconnect is called", () => {
72
+ const config = { url: "wss://api.example.com/ws" };
73
+ const { result } = renderHook(() => useWebSocket(config));
74
+
75
+ const instance = MockWebSocketManager.mock.results[0].value;
76
+ instance.disconnect.mockClear();
77
+
78
+ act(() => {
79
+ result.current.disconnect();
80
+ });
81
+
82
+ expect(instance.disconnect).toHaveBeenCalled();
83
+ });
84
+
85
+ it("should call manager.send when send is called", () => {
86
+ const config = { url: "wss://api.example.com/ws" };
87
+ const { result } = renderHook(() => useWebSocket(config));
88
+
89
+ const instance = MockWebSocketManager.mock.results[0].value;
90
+
91
+ act(() => {
92
+ result.current.send("chat:message", { text: "Hello" }, "room-1");
93
+ });
94
+
95
+ expect(instance.send).toHaveBeenCalledWith(
96
+ "chat:message",
97
+ { text: "Hello" },
98
+ "room-1"
99
+ );
100
+ });
101
+
102
+ it("should track connection status via onStatusChange", () => {
103
+ const config = { url: "wss://api.example.com/ws" };
104
+ const { result } = renderHook(() => useWebSocket(config));
105
+
106
+ expect(result.current.status).toBe("disconnected");
107
+
108
+ // Get the status callback registered with onStatusChange
109
+ const instance = MockWebSocketManager.mock.results[0].value;
110
+ const statusCallback = instance.onStatusChange.mock.calls[0][0];
111
+
112
+ act(() => {
113
+ statusCallback("connected");
114
+ });
115
+
116
+ expect(result.current.status).toBe("connected");
117
+ });
118
+ });
119
+
120
+ describe("useChannel", () => {
121
+ let mockManager: any;
122
+
123
+ beforeEach(() => {
124
+ jest.clearAllMocks();
125
+ mockManager = {
126
+ connect: jest.fn().mockResolvedValue(undefined),
127
+ disconnect: jest.fn(),
128
+ send: jest.fn(),
129
+ subscribe: jest.fn().mockReturnValue(jest.fn()),
130
+ onStatusChange: jest.fn().mockReturnValue(jest.fn()),
131
+ onMessage: jest.fn().mockReturnValue(jest.fn()),
132
+ getStatus: jest.fn().mockReturnValue("connected"),
133
+ };
134
+ });
135
+
136
+ it("should subscribe to channel on mount", () => {
137
+ renderHook(() => useChannel(mockManager, "chat:room-1"));
138
+
139
+ expect(mockManager.subscribe).toHaveBeenCalledWith(
140
+ "chat:room-1",
141
+ expect.any(Function)
142
+ );
143
+ });
144
+
145
+ it("should unsubscribe on unmount", () => {
146
+ const mockUnsubscribe = jest.fn();
147
+ mockManager.subscribe.mockReturnValue(mockUnsubscribe);
148
+
149
+ const { unmount } = renderHook(() =>
150
+ useChannel(mockManager, "chat:room-1")
151
+ );
152
+
153
+ unmount();
154
+
155
+ expect(mockUnsubscribe).toHaveBeenCalled();
156
+ });
157
+
158
+ it("should accumulate messages", () => {
159
+ const { result } = renderHook(() => useChannel(mockManager, "chat:room-1"));
160
+
161
+ const messageHandler = mockManager.subscribe.mock.calls[0][1];
162
+
163
+ const msg1 = {
164
+ type: "chat:message",
165
+ channel: "chat:room-1",
166
+ payload: { text: "Hello" },
167
+ timestamp: "2024-01-01T00:00:00.000Z",
168
+ };
169
+ const msg2 = {
170
+ type: "chat:message",
171
+ channel: "chat:room-1",
172
+ payload: { text: "World" },
173
+ timestamp: "2024-01-01T00:00:01.000Z",
174
+ };
175
+
176
+ act(() => {
177
+ messageHandler(msg1);
178
+ });
179
+
180
+ act(() => {
181
+ messageHandler(msg2);
182
+ });
183
+
184
+ expect(result.current.messages).toHaveLength(2);
185
+ expect(result.current.messages[0].payload).toEqual({ text: "Hello" });
186
+ expect(result.current.messages[1].payload).toEqual({ text: "World" });
187
+ expect(result.current.lastMessage).toEqual(msg2);
188
+ });
189
+
190
+ it("should respect maxMessages limit", () => {
191
+ const { result } = renderHook(() =>
192
+ useChannel(mockManager, "chat:room-1", { maxMessages: 2 })
193
+ );
194
+
195
+ const messageHandler = mockManager.subscribe.mock.calls[0][1];
196
+
197
+ act(() => {
198
+ messageHandler({
199
+ type: "chat:message",
200
+ payload: { text: "1" },
201
+ timestamp: "2024-01-01T00:00:00.000Z",
202
+ });
203
+ });
204
+ act(() => {
205
+ messageHandler({
206
+ type: "chat:message",
207
+ payload: { text: "2" },
208
+ timestamp: "2024-01-01T00:00:01.000Z",
209
+ });
210
+ });
211
+ act(() => {
212
+ messageHandler({
213
+ type: "chat:message",
214
+ payload: { text: "3" },
215
+ timestamp: "2024-01-01T00:00:02.000Z",
216
+ });
217
+ });
218
+
219
+ expect(result.current.messages).toHaveLength(2);
220
+ // The oldest message should be dropped
221
+ expect(result.current.messages[0].payload).toEqual({ text: "2" });
222
+ expect(result.current.messages[1].payload).toEqual({ text: "3" });
223
+ });
224
+ });
225
+
226
+ describe("usePresence", () => {
227
+ let mockManager: any;
228
+
229
+ beforeEach(() => {
230
+ jest.clearAllMocks();
231
+ mockManager = {
232
+ connect: jest.fn().mockResolvedValue(undefined),
233
+ disconnect: jest.fn(),
234
+ send: jest.fn(),
235
+ subscribe: jest.fn().mockReturnValue(jest.fn()),
236
+ onStatusChange: jest.fn().mockReturnValue(jest.fn()),
237
+ onMessage: jest.fn().mockReturnValue(jest.fn()),
238
+ getStatus: jest.fn().mockReturnValue("connected"),
239
+ };
240
+ });
241
+
242
+ it("should track users from presence_join events", () => {
243
+ const { result } = renderHook(() => usePresence(mockManager, "room:lobby"));
244
+
245
+ const messageHandler = mockManager.subscribe.mock.calls[0][1];
246
+
247
+ act(() => {
248
+ messageHandler({
249
+ type: "presence_join",
250
+ channel: "room:lobby",
251
+ payload: {
252
+ id: "user-1",
253
+ name: "Alice",
254
+ lastSeen: "2024-01-01T00:00:00.000Z",
255
+ },
256
+ timestamp: "2024-01-01T00:00:00.000Z",
257
+ });
258
+ });
259
+
260
+ expect(result.current.onlineUsers).toHaveLength(1);
261
+ expect(result.current.onlineUsers[0].id).toBe("user-1");
262
+ expect(result.current.onlineUsers[0].name).toBe("Alice");
263
+ expect(result.current.isUserOnline("user-1")).toBe(true);
264
+ });
265
+
266
+ it("should remove users from presence_leave events", () => {
267
+ const { result } = renderHook(() => usePresence(mockManager, "room:lobby"));
268
+
269
+ const messageHandler = mockManager.subscribe.mock.calls[0][1];
270
+
271
+ // Add a user
272
+ act(() => {
273
+ messageHandler({
274
+ type: "presence_join",
275
+ channel: "room:lobby",
276
+ payload: {
277
+ id: "user-1",
278
+ name: "Alice",
279
+ lastSeen: "2024-01-01T00:00:00.000Z",
280
+ },
281
+ timestamp: "2024-01-01T00:00:00.000Z",
282
+ });
283
+ });
284
+
285
+ expect(result.current.onlineUsers).toHaveLength(1);
286
+
287
+ // Remove the user
288
+ act(() => {
289
+ messageHandler({
290
+ type: "presence_leave",
291
+ channel: "room:lobby",
292
+ payload: { id: "user-1" },
293
+ timestamp: "2024-01-01T00:00:01.000Z",
294
+ });
295
+ });
296
+
297
+ expect(result.current.onlineUsers).toHaveLength(0);
298
+ expect(result.current.isUserOnline("user-1")).toBe(false);
299
+ });
300
+
301
+ it("should sync users from presence_sync events", () => {
302
+ const { result } = renderHook(() => usePresence(mockManager, "room:lobby"));
303
+
304
+ const messageHandler = mockManager.subscribe.mock.calls[0][1];
305
+
306
+ act(() => {
307
+ messageHandler({
308
+ type: "presence_sync",
309
+ channel: "room:lobby",
310
+ payload: [
311
+ { id: "user-1", name: "Alice", lastSeen: "2024-01-01T00:00:00.000Z" },
312
+ { id: "user-2", name: "Bob", lastSeen: "2024-01-01T00:00:00.000Z" },
313
+ {
314
+ id: "user-3",
315
+ name: "Charlie",
316
+ lastSeen: "2024-01-01T00:00:00.000Z",
317
+ },
318
+ ],
319
+ timestamp: "2024-01-01T00:00:00.000Z",
320
+ });
321
+ });
322
+
323
+ expect(result.current.onlineUsers).toHaveLength(3);
324
+ expect(result.current.isUserOnline("user-1")).toBe(true);
325
+ expect(result.current.isUserOnline("user-2")).toBe(true);
326
+ expect(result.current.isUserOnline("user-3")).toBe(true);
327
+ expect(result.current.isUserOnline("user-4")).toBe(false);
328
+ });
329
+ });