@croacroa/react-native-template 2.0.1 → 3.2.0

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