@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.
- package/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -0
- package/README.md +446 -399
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -0
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -23
- package/constants/config.ts +135 -97
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -27
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +64 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -0
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -175
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- package/utils/withAccessibility.tsx +272 -272
|
@@ -1,626 +1,652 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Background sync service for offline mutation queue
|
|
3
|
-
* Uses expo-task-manager to process queued mutations when the app is in background.
|
|
4
|
-
* @module services/backgroundSync
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import * as TaskManager from "expo-task-manager";
|
|
8
|
-
import * as BackgroundFetch from "expo-background-fetch";
|
|
9
|
-
import { storage } from "./storage";
|
|
10
|
-
import { api } from "./api";
|
|
11
|
-
import { IS_DEV } from "@/constants/config";
|
|
12
|
-
|
|
13
|
-
// Task names
|
|
14
|
-
const BACKGROUND_SYNC_TASK = "BACKGROUND_SYNC_TASK";
|
|
15
|
-
const MUTATION_QUEUE_KEY = "offline_mutation_queue";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Conflict resolution strategies
|
|
19
|
-
*/
|
|
20
|
-
export type ConflictResolutionStrategy =
|
|
21
|
-
| "last-write-wins"
|
|
22
|
-
| "first-write-wins"
|
|
23
|
-
| "merge"
|
|
24
|
-
| "manual"
|
|
25
|
-
| "server-wins"
|
|
26
|
-
| "client-wins";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Conflict information when a sync conflict is detected
|
|
30
|
-
*/
|
|
31
|
-
export interface SyncConflict {
|
|
32
|
-
/** The local mutation that caused the conflict */
|
|
33
|
-
localMutation: QueuedMutation;
|
|
34
|
-
/** Server data at the time of conflict (if available) */
|
|
35
|
-
serverData?: Record<string, unknown>;
|
|
36
|
-
/** Error message from the server */
|
|
37
|
-
errorMessage: string;
|
|
38
|
-
/** HTTP status code */
|
|
39
|
-
statusCode: number;
|
|
40
|
-
/** Timestamp when conflict was detected */
|
|
41
|
-
detectedAt: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Conflict resolver function type
|
|
46
|
-
*/
|
|
47
|
-
export type ConflictResolver = (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
*/
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// ============================================================================
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* ...conflict.
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* }
|
|
223
|
-
*
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
case "
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
case "
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// ============================================================================
|
|
514
|
-
//
|
|
515
|
-
// ============================================================================
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
*
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Background sync service for offline mutation queue
|
|
3
|
+
* Uses expo-task-manager to process queued mutations when the app is in background.
|
|
4
|
+
* @module services/backgroundSync
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as TaskManager from "expo-task-manager";
|
|
8
|
+
import * as BackgroundFetch from "expo-background-fetch";
|
|
9
|
+
import { storage } from "./storage";
|
|
10
|
+
import { api } from "./api";
|
|
11
|
+
import { IS_DEV } from "@/constants/config";
|
|
12
|
+
|
|
13
|
+
// Task names
|
|
14
|
+
const BACKGROUND_SYNC_TASK = "BACKGROUND_SYNC_TASK";
|
|
15
|
+
const MUTATION_QUEUE_KEY = "offline_mutation_queue";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Conflict resolution strategies
|
|
19
|
+
*/
|
|
20
|
+
export type ConflictResolutionStrategy =
|
|
21
|
+
| "last-write-wins"
|
|
22
|
+
| "first-write-wins"
|
|
23
|
+
| "merge"
|
|
24
|
+
| "manual"
|
|
25
|
+
| "server-wins"
|
|
26
|
+
| "client-wins";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Conflict information when a sync conflict is detected
|
|
30
|
+
*/
|
|
31
|
+
export interface SyncConflict {
|
|
32
|
+
/** The local mutation that caused the conflict */
|
|
33
|
+
localMutation: QueuedMutation;
|
|
34
|
+
/** Server data at the time of conflict (if available) */
|
|
35
|
+
serverData?: Record<string, unknown>;
|
|
36
|
+
/** Error message from the server */
|
|
37
|
+
errorMessage: string;
|
|
38
|
+
/** HTTP status code */
|
|
39
|
+
statusCode: number;
|
|
40
|
+
/** Timestamp when conflict was detected */
|
|
41
|
+
detectedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Conflict resolver function type
|
|
46
|
+
*/
|
|
47
|
+
export type ConflictResolver = (conflict: SyncConflict) => Promise<{
|
|
48
|
+
action: "retry" | "discard" | "merge";
|
|
49
|
+
mergedData?: Record<string, unknown>;
|
|
50
|
+
}>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Queued mutation structure
|
|
54
|
+
*/
|
|
55
|
+
export interface QueuedMutation {
|
|
56
|
+
/** Unique identifier for the mutation */
|
|
57
|
+
id: string;
|
|
58
|
+
/** Timestamp when the mutation was queued */
|
|
59
|
+
timestamp: number;
|
|
60
|
+
/** HTTP method */
|
|
61
|
+
method: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
62
|
+
/** API endpoint */
|
|
63
|
+
endpoint: string;
|
|
64
|
+
/** Request body */
|
|
65
|
+
body?: Record<string, unknown>;
|
|
66
|
+
/** Number of retry attempts */
|
|
67
|
+
retryCount: number;
|
|
68
|
+
/** Maximum retries before giving up */
|
|
69
|
+
maxRetries: number;
|
|
70
|
+
/** Conflict resolution strategy for this mutation */
|
|
71
|
+
conflictStrategy?: ConflictResolutionStrategy;
|
|
72
|
+
/** Version/ETag for optimistic locking */
|
|
73
|
+
version?: string | number;
|
|
74
|
+
/** Optional metadata for tracking */
|
|
75
|
+
metadata?: {
|
|
76
|
+
type: string;
|
|
77
|
+
entityId?: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sync result for a single mutation
|
|
84
|
+
*/
|
|
85
|
+
interface SyncResult {
|
|
86
|
+
id: string;
|
|
87
|
+
success: boolean;
|
|
88
|
+
error?: string;
|
|
89
|
+
conflict?: SyncConflict;
|
|
90
|
+
statusCode?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Background sync configuration
|
|
95
|
+
*/
|
|
96
|
+
const SYNC_CONFIG = {
|
|
97
|
+
/** Minimum interval between background fetches (in seconds) */
|
|
98
|
+
MINIMUM_INTERVAL: 15 * 60, // 15 minutes
|
|
99
|
+
/** Maximum retries for a single mutation */
|
|
100
|
+
MAX_RETRIES: 5,
|
|
101
|
+
/** Timeout for the entire sync task (in seconds) */
|
|
102
|
+
TASK_TIMEOUT: 30,
|
|
103
|
+
/** Default conflict resolution strategy */
|
|
104
|
+
DEFAULT_CONFLICT_STRATEGY: "last-write-wins" as ConflictResolutionStrategy,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Store for conflict handlers
|
|
108
|
+
const conflictHandlers: Map<string, ConflictResolver> = new Map();
|
|
109
|
+
const pendingConflicts: SyncConflict[] = [];
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Mutation Queue Management
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Add a mutation to the offline queue
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* // In your mutation hook
|
|
121
|
+
* const createPost = useMutation({
|
|
122
|
+
* mutationFn: async (data) => {
|
|
123
|
+
* try {
|
|
124
|
+
* return await api.post('/posts', data);
|
|
125
|
+
* } catch (error) {
|
|
126
|
+
* if (isNetworkError(error)) {
|
|
127
|
+
* await queueMutation({
|
|
128
|
+
* method: 'POST',
|
|
129
|
+
* endpoint: '/posts',
|
|
130
|
+
* body: data,
|
|
131
|
+
* metadata: { type: 'create_post' },
|
|
132
|
+
* });
|
|
133
|
+
* throw new Error('Queued for sync');
|
|
134
|
+
* }
|
|
135
|
+
* throw error;
|
|
136
|
+
* }
|
|
137
|
+
* },
|
|
138
|
+
* });
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export async function queueMutation(
|
|
142
|
+
mutation: Omit<
|
|
143
|
+
QueuedMutation,
|
|
144
|
+
"id" | "timestamp" | "retryCount" | "maxRetries"
|
|
145
|
+
>
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const queue = await getMutationQueue();
|
|
148
|
+
|
|
149
|
+
const queuedMutation: QueuedMutation = {
|
|
150
|
+
...mutation,
|
|
151
|
+
id: generateId(),
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
retryCount: 0,
|
|
154
|
+
maxRetries: SYNC_CONFIG.MAX_RETRIES,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
queue.push(queuedMutation);
|
|
158
|
+
await saveMutationQueue(queue);
|
|
159
|
+
|
|
160
|
+
if (IS_DEV) {
|
|
161
|
+
console.log("[BackgroundSync] Queued mutation:", queuedMutation.id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return queuedMutation.id;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the current mutation queue
|
|
169
|
+
*/
|
|
170
|
+
export async function getMutationQueue(): Promise<QueuedMutation[]> {
|
|
171
|
+
const queue = await storage.get<QueuedMutation[]>(MUTATION_QUEUE_KEY);
|
|
172
|
+
return queue || [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Save the mutation queue
|
|
177
|
+
*/
|
|
178
|
+
async function saveMutationQueue(queue: QueuedMutation[]): Promise<void> {
|
|
179
|
+
await storage.set(MUTATION_QUEUE_KEY, queue);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Remove a mutation from the queue
|
|
184
|
+
*/
|
|
185
|
+
export async function removeMutation(id: string): Promise<void> {
|
|
186
|
+
const queue = await getMutationQueue();
|
|
187
|
+
const filtered = queue.filter((m) => m.id !== id);
|
|
188
|
+
await saveMutationQueue(filtered);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Clear all mutations from the queue
|
|
193
|
+
*/
|
|
194
|
+
export async function clearMutationQueue(): Promise<void> {
|
|
195
|
+
await storage.set(MUTATION_QUEUE_KEY, []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get the number of pending mutations
|
|
200
|
+
*/
|
|
201
|
+
export async function getPendingMutationCount(): Promise<number> {
|
|
202
|
+
const queue = await getMutationQueue();
|
|
203
|
+
return queue.length;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Conflict Resolution
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Register a custom conflict resolver for a mutation type
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* registerConflictResolver('update_profile', async (conflict) => {
|
|
216
|
+
* // Custom merge logic
|
|
217
|
+
* const merged = {
|
|
218
|
+
* ...conflict.serverData,
|
|
219
|
+
* ...conflict.localMutation.body,
|
|
220
|
+
* updatedAt: Date.now(),
|
|
221
|
+
* };
|
|
222
|
+
* return { action: 'merge', mergedData: merged };
|
|
223
|
+
* });
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
export function registerConflictResolver(
|
|
227
|
+
mutationType: string,
|
|
228
|
+
resolver: ConflictResolver
|
|
229
|
+
): void {
|
|
230
|
+
conflictHandlers.set(mutationType, resolver);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Unregister a conflict resolver
|
|
235
|
+
*/
|
|
236
|
+
export function unregisterConflictResolver(mutationType: string): void {
|
|
237
|
+
conflictHandlers.delete(mutationType);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get pending conflicts that need manual resolution
|
|
242
|
+
*/
|
|
243
|
+
export function getPendingConflicts(): SyncConflict[] {
|
|
244
|
+
return [...pendingConflicts];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Clear a pending conflict after manual resolution
|
|
249
|
+
*/
|
|
250
|
+
export function clearPendingConflict(mutationId: string): void {
|
|
251
|
+
const index = pendingConflicts.findIndex(
|
|
252
|
+
(c) => c.localMutation.id === mutationId
|
|
253
|
+
);
|
|
254
|
+
if (index !== -1) {
|
|
255
|
+
pendingConflicts.splice(index, 1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve a conflict using the default strategy
|
|
261
|
+
*/
|
|
262
|
+
async function resolveConflictWithStrategy(
|
|
263
|
+
conflict: SyncConflict,
|
|
264
|
+
strategy: ConflictResolutionStrategy
|
|
265
|
+
): Promise<{
|
|
266
|
+
action: "retry" | "discard" | "merge";
|
|
267
|
+
mergedData?: Record<string, unknown>;
|
|
268
|
+
}> {
|
|
269
|
+
switch (strategy) {
|
|
270
|
+
case "last-write-wins":
|
|
271
|
+
case "client-wins":
|
|
272
|
+
// Client data takes precedence, retry with same data
|
|
273
|
+
return { action: "retry" };
|
|
274
|
+
|
|
275
|
+
case "first-write-wins":
|
|
276
|
+
case "server-wins":
|
|
277
|
+
// Server data takes precedence, discard local changes
|
|
278
|
+
return { action: "discard" };
|
|
279
|
+
|
|
280
|
+
case "merge":
|
|
281
|
+
// Attempt automatic merge
|
|
282
|
+
if (conflict.serverData && conflict.localMutation.body) {
|
|
283
|
+
const mergedData = {
|
|
284
|
+
...conflict.serverData,
|
|
285
|
+
...conflict.localMutation.body,
|
|
286
|
+
_mergedAt: Date.now(),
|
|
287
|
+
_conflictResolved: true,
|
|
288
|
+
};
|
|
289
|
+
return { action: "merge", mergedData };
|
|
290
|
+
}
|
|
291
|
+
return { action: "retry" };
|
|
292
|
+
|
|
293
|
+
case "manual":
|
|
294
|
+
// Add to pending conflicts for user resolution
|
|
295
|
+
pendingConflicts.push(conflict);
|
|
296
|
+
return { action: "discard" }; // Don't retry automatically
|
|
297
|
+
|
|
298
|
+
default:
|
|
299
|
+
return { action: "retry" };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Check if an error indicates a conflict (409 or version mismatch)
|
|
305
|
+
*/
|
|
306
|
+
function isConflictError(error: unknown): {
|
|
307
|
+
isConflict: boolean;
|
|
308
|
+
statusCode?: number;
|
|
309
|
+
serverData?: Record<string, unknown>;
|
|
310
|
+
} {
|
|
311
|
+
if (error && typeof error === "object" && "status" in error) {
|
|
312
|
+
const status = (error as { status: number }).status;
|
|
313
|
+
if (status === 409 || status === 412) {
|
|
314
|
+
// 409 Conflict or 412 Precondition Failed
|
|
315
|
+
const serverData =
|
|
316
|
+
"data" in error
|
|
317
|
+
? (error as { data?: Record<string, unknown> }).data
|
|
318
|
+
: undefined;
|
|
319
|
+
return { isConflict: true, statusCode: status, serverData };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { isConflict: false };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// Sync Processing
|
|
327
|
+
// ============================================================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Process a single mutation with conflict handling
|
|
331
|
+
*/
|
|
332
|
+
async function processMutation(mutation: QueuedMutation): Promise<SyncResult> {
|
|
333
|
+
try {
|
|
334
|
+
// Add version header if available for optimistic locking
|
|
335
|
+
const headers: Record<string, string> = {};
|
|
336
|
+
if (mutation.version) {
|
|
337
|
+
headers["If-Match"] = String(mutation.version);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
switch (mutation.method) {
|
|
341
|
+
case "POST":
|
|
342
|
+
await api.post(mutation.endpoint, mutation.body);
|
|
343
|
+
break;
|
|
344
|
+
case "PUT":
|
|
345
|
+
await api.put(mutation.endpoint, mutation.body);
|
|
346
|
+
break;
|
|
347
|
+
case "PATCH":
|
|
348
|
+
await api.patch(mutation.endpoint, mutation.body);
|
|
349
|
+
break;
|
|
350
|
+
case "DELETE":
|
|
351
|
+
await api.delete(mutation.endpoint);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { id: mutation.id, success: true };
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
358
|
+
const conflictCheck = isConflictError(error);
|
|
359
|
+
|
|
360
|
+
if (conflictCheck.isConflict) {
|
|
361
|
+
const conflict: SyncConflict = {
|
|
362
|
+
localMutation: mutation,
|
|
363
|
+
serverData: conflictCheck.serverData,
|
|
364
|
+
errorMessage: message,
|
|
365
|
+
statusCode: conflictCheck.statusCode || 409,
|
|
366
|
+
detectedAt: Date.now(),
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
id: mutation.id,
|
|
371
|
+
success: false,
|
|
372
|
+
error: message,
|
|
373
|
+
conflict,
|
|
374
|
+
statusCode: conflictCheck.statusCode,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { id: mutation.id, success: false, error: message };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Process all pending mutations with conflict resolution
|
|
384
|
+
* Called by background task and can also be triggered manually
|
|
385
|
+
*/
|
|
386
|
+
export async function processQueue(): Promise<{
|
|
387
|
+
processed: number;
|
|
388
|
+
succeeded: number;
|
|
389
|
+
failed: number;
|
|
390
|
+
conflicts: number;
|
|
391
|
+
remaining: number;
|
|
392
|
+
}> {
|
|
393
|
+
const queue = await getMutationQueue();
|
|
394
|
+
|
|
395
|
+
if (queue.length === 0) {
|
|
396
|
+
return {
|
|
397
|
+
processed: 0,
|
|
398
|
+
succeeded: 0,
|
|
399
|
+
failed: 0,
|
|
400
|
+
conflicts: 0,
|
|
401
|
+
remaining: 0,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (IS_DEV) {
|
|
406
|
+
console.log(`[BackgroundSync] Processing ${queue.length} mutations`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const results: SyncResult[] = [];
|
|
410
|
+
const updatedQueue: QueuedMutation[] = [];
|
|
411
|
+
let conflictCount = 0;
|
|
412
|
+
|
|
413
|
+
for (const mutation of queue) {
|
|
414
|
+
const result = await processMutation(mutation);
|
|
415
|
+
results.push(result);
|
|
416
|
+
|
|
417
|
+
if (!result.success) {
|
|
418
|
+
// Check if this is a conflict
|
|
419
|
+
if (result.conflict) {
|
|
420
|
+
conflictCount++;
|
|
421
|
+
const strategy =
|
|
422
|
+
mutation.conflictStrategy || SYNC_CONFIG.DEFAULT_CONFLICT_STRATEGY;
|
|
423
|
+
|
|
424
|
+
// Check for custom resolver first
|
|
425
|
+
const customResolver = mutation.metadata?.type
|
|
426
|
+
? conflictHandlers.get(mutation.metadata.type)
|
|
427
|
+
: null;
|
|
428
|
+
|
|
429
|
+
let resolution: {
|
|
430
|
+
action: "retry" | "discard" | "merge";
|
|
431
|
+
mergedData?: Record<string, unknown>;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
if (customResolver) {
|
|
435
|
+
resolution = await customResolver(result.conflict);
|
|
436
|
+
} else {
|
|
437
|
+
resolution = await resolveConflictWithStrategy(
|
|
438
|
+
result.conflict,
|
|
439
|
+
strategy
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (IS_DEV) {
|
|
444
|
+
console.log(
|
|
445
|
+
`[BackgroundSync] Conflict for ${mutation.id} resolved with action: ${resolution.action}`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
switch (resolution.action) {
|
|
450
|
+
case "retry":
|
|
451
|
+
if (mutation.retryCount < mutation.maxRetries) {
|
|
452
|
+
updatedQueue.push({
|
|
453
|
+
...mutation,
|
|
454
|
+
retryCount: mutation.retryCount + 1,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
case "merge":
|
|
459
|
+
if (
|
|
460
|
+
resolution.mergedData &&
|
|
461
|
+
mutation.retryCount < mutation.maxRetries
|
|
462
|
+
) {
|
|
463
|
+
updatedQueue.push({
|
|
464
|
+
...mutation,
|
|
465
|
+
body: resolution.mergedData,
|
|
466
|
+
retryCount: mutation.retryCount + 1,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
case "discard":
|
|
471
|
+
// Don't add to queue, effectively discarding
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
// Regular failure, check if we should retry
|
|
476
|
+
if (mutation.retryCount < mutation.maxRetries) {
|
|
477
|
+
updatedQueue.push({
|
|
478
|
+
...mutation,
|
|
479
|
+
retryCount: mutation.retryCount + 1,
|
|
480
|
+
});
|
|
481
|
+
} else {
|
|
482
|
+
// Max retries reached, log and discard
|
|
483
|
+
console.warn(
|
|
484
|
+
`[BackgroundSync] Mutation ${mutation.id} failed after ${mutation.maxRetries} retries:`,
|
|
485
|
+
result.error
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Save remaining mutations
|
|
493
|
+
await saveMutationQueue(updatedQueue);
|
|
494
|
+
|
|
495
|
+
const succeeded = results.filter((r) => r.success).length;
|
|
496
|
+
const failed = results.filter((r) => !r.success).length;
|
|
497
|
+
|
|
498
|
+
if (IS_DEV) {
|
|
499
|
+
console.log(
|
|
500
|
+
`[BackgroundSync] Processed: ${results.length}, Succeeded: ${succeeded}, Failed: ${failed}, Conflicts: ${conflictCount}, Remaining: ${updatedQueue.length}`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
processed: results.length,
|
|
506
|
+
succeeded,
|
|
507
|
+
failed,
|
|
508
|
+
conflicts: conflictCount,
|
|
509
|
+
remaining: updatedQueue.length,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============================================================================
|
|
514
|
+
// Background Task Definition
|
|
515
|
+
// ============================================================================
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Define the background sync task
|
|
519
|
+
*/
|
|
520
|
+
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
|
521
|
+
try {
|
|
522
|
+
const result = await processQueue();
|
|
523
|
+
|
|
524
|
+
if (result.processed === 0) {
|
|
525
|
+
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (result.failed > 0 && result.succeeded === 0) {
|
|
529
|
+
return BackgroundFetch.BackgroundFetchResult.Failed;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return BackgroundFetch.BackgroundFetchResult.NewData;
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.error("[BackgroundSync] Task error:", error);
|
|
535
|
+
return BackgroundFetch.BackgroundFetchResult.Failed;
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// ============================================================================
|
|
540
|
+
// Registration Functions
|
|
541
|
+
// ============================================================================
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Register the background sync task
|
|
545
|
+
* Call this during app initialization
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* ```tsx
|
|
549
|
+
* // In your app root or initialization
|
|
550
|
+
* useEffect(() => {
|
|
551
|
+
* registerBackgroundSync();
|
|
552
|
+
* }, []);
|
|
553
|
+
* ```
|
|
554
|
+
*/
|
|
555
|
+
export async function registerBackgroundSync(): Promise<boolean> {
|
|
556
|
+
try {
|
|
557
|
+
// Check if background fetch is available
|
|
558
|
+
const status = await BackgroundFetch.getStatusAsync();
|
|
559
|
+
|
|
560
|
+
if (status === BackgroundFetch.BackgroundFetchStatus.Restricted) {
|
|
561
|
+
console.warn("[BackgroundSync] Background fetch is restricted");
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (status === BackgroundFetch.BackgroundFetchStatus.Denied) {
|
|
566
|
+
console.warn("[BackgroundSync] Background fetch is denied");
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Register the task
|
|
571
|
+
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
|
|
572
|
+
minimumInterval: SYNC_CONFIG.MINIMUM_INTERVAL,
|
|
573
|
+
stopOnTerminate: false,
|
|
574
|
+
startOnBoot: true,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
if (IS_DEV) {
|
|
578
|
+
console.log("[BackgroundSync] Background sync registered");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return true;
|
|
582
|
+
} catch (error) {
|
|
583
|
+
console.error("[BackgroundSync] Registration failed:", error);
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Unregister the background sync task
|
|
590
|
+
*/
|
|
591
|
+
export async function unregisterBackgroundSync(): Promise<void> {
|
|
592
|
+
try {
|
|
593
|
+
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_SYNC_TASK);
|
|
594
|
+
if (IS_DEV) {
|
|
595
|
+
console.log("[BackgroundSync] Background sync unregistered");
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.error("[BackgroundSync] Unregistration failed:", error);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Check if background sync is registered
|
|
604
|
+
*/
|
|
605
|
+
export async function isBackgroundSyncRegistered(): Promise<boolean> {
|
|
606
|
+
return TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Get background fetch status
|
|
611
|
+
*/
|
|
612
|
+
export async function getBackgroundSyncStatus(): Promise<{
|
|
613
|
+
isAvailable: boolean;
|
|
614
|
+
isRegistered: boolean;
|
|
615
|
+
status: BackgroundFetch.BackgroundFetchStatus;
|
|
616
|
+
}> {
|
|
617
|
+
const status = await BackgroundFetch.getStatusAsync();
|
|
618
|
+
const isRegistered = await isBackgroundSyncRegistered();
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
isAvailable: status === BackgroundFetch.BackgroundFetchStatus.Available,
|
|
622
|
+
isRegistered,
|
|
623
|
+
status,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ============================================================================
|
|
628
|
+
// Utilities
|
|
629
|
+
// ============================================================================
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Generate a unique ID for mutations
|
|
633
|
+
*/
|
|
634
|
+
function generateId(): string {
|
|
635
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Check if an error is a network error
|
|
640
|
+
*/
|
|
641
|
+
export function isNetworkError(error: unknown): boolean {
|
|
642
|
+
if (error instanceof Error) {
|
|
643
|
+
const message = error.message.toLowerCase();
|
|
644
|
+
return (
|
|
645
|
+
message.includes("network") ||
|
|
646
|
+
message.includes("fetch") ||
|
|
647
|
+
message.includes("timeout") ||
|
|
648
|
+
message.includes("offline")
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return false;
|
|
652
|
+
}
|