@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
package/hooks/useUpdates.ts
CHANGED
|
@@ -1,358 +1,358 @@
|
|
|
1
|
-
import { useEffect, useState, useCallback } from "react";
|
|
2
|
-
import * as Updates from "expo-updates";
|
|
3
|
-
import { Alert, AppState, AppStateStatus } from "react-native";
|
|
4
|
-
import { IS_DEV } from "@/constants/config";
|
|
5
|
-
|
|
6
|
-
export type UpdateStatus =
|
|
7
|
-
| "idle"
|
|
8
|
-
| "checking"
|
|
9
|
-
| "available"
|
|
10
|
-
| "downloading"
|
|
11
|
-
| "ready"
|
|
12
|
-
| "error"
|
|
13
|
-
| "no-update";
|
|
14
|
-
|
|
15
|
-
interface UpdateInfo {
|
|
16
|
-
/**
|
|
17
|
-
* Whether an update is available
|
|
18
|
-
*/
|
|
19
|
-
isAvailable: boolean;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Current update status
|
|
23
|
-
*/
|
|
24
|
-
status: UpdateStatus;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Download progress (0-100)
|
|
28
|
-
*/
|
|
29
|
-
progress: number;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Error message if status is 'error'
|
|
33
|
-
*/
|
|
34
|
-
error: string | null;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Update manifest info
|
|
38
|
-
*/
|
|
39
|
-
manifest: Updates.Manifest | null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface UseUpdatesOptions {
|
|
43
|
-
/**
|
|
44
|
-
* Check for updates on mount
|
|
45
|
-
* @default true
|
|
46
|
-
*/
|
|
47
|
-
checkOnMount?: boolean;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Check for updates when app returns to foreground
|
|
51
|
-
* @default true
|
|
52
|
-
*/
|
|
53
|
-
checkOnForeground?: boolean;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Show alert when update is available
|
|
57
|
-
* @default true
|
|
58
|
-
*/
|
|
59
|
-
showAlert?: boolean;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Auto download and apply updates
|
|
63
|
-
* @default false
|
|
64
|
-
*/
|
|
65
|
-
autoUpdate?: boolean;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Callback when update is available
|
|
69
|
-
*/
|
|
70
|
-
onUpdateAvailable?: (manifest: Updates.Manifest) => void;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Callback when update is downloaded and ready
|
|
74
|
-
*/
|
|
75
|
-
onUpdateReady?: () => void;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Callback on error
|
|
79
|
-
*/
|
|
80
|
-
onError?: (error: Error) => void;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface UseUpdatesReturn extends UpdateInfo {
|
|
84
|
-
/**
|
|
85
|
-
* Manually check for updates
|
|
86
|
-
*/
|
|
87
|
-
checkForUpdate: () => Promise<boolean>;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Download the available update
|
|
91
|
-
*/
|
|
92
|
-
downloadUpdate: () => Promise<void>;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Apply the downloaded update (restarts the app)
|
|
96
|
-
*/
|
|
97
|
-
applyUpdate: () => Promise<void>;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Reset update state
|
|
101
|
-
*/
|
|
102
|
-
reset: () => void;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Hook for managing OTA updates with expo-updates
|
|
107
|
-
*
|
|
108
|
-
* @example
|
|
109
|
-
* ```tsx
|
|
110
|
-
* function App() {
|
|
111
|
-
* const { status, isAvailable, checkForUpdate, applyUpdate } = useUpdates({
|
|
112
|
-
* checkOnMount: true,
|
|
113
|
-
* showAlert: true,
|
|
114
|
-
* });
|
|
115
|
-
*
|
|
116
|
-
* if (status === 'ready') {
|
|
117
|
-
* return (
|
|
118
|
-
* <Button onPress={applyUpdate}>
|
|
119
|
-
* Restart to update
|
|
120
|
-
* </Button>
|
|
121
|
-
* );
|
|
122
|
-
* }
|
|
123
|
-
*
|
|
124
|
-
* return <App />;
|
|
125
|
-
* }
|
|
126
|
-
* ```
|
|
127
|
-
*/
|
|
128
|
-
export function useUpdates(options: UseUpdatesOptions = {}): UseUpdatesReturn {
|
|
129
|
-
const {
|
|
130
|
-
checkOnMount = true,
|
|
131
|
-
checkOnForeground = true,
|
|
132
|
-
showAlert = true,
|
|
133
|
-
autoUpdate = false,
|
|
134
|
-
onUpdateAvailable,
|
|
135
|
-
onUpdateReady,
|
|
136
|
-
onError,
|
|
137
|
-
} = options;
|
|
138
|
-
|
|
139
|
-
const [status, setStatus] = useState<UpdateStatus>("idle");
|
|
140
|
-
const [progress, setProgress] = useState(0);
|
|
141
|
-
const [error, setError] = useState<string | null>(null);
|
|
142
|
-
const [manifest, setManifest] = useState<Updates.Manifest | null>(null);
|
|
143
|
-
|
|
144
|
-
const isAvailable =
|
|
145
|
-
status === "available" || status === "downloading" || status === "ready";
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Download the available update
|
|
149
|
-
*/
|
|
150
|
-
const downloadUpdate = useCallback(async (): Promise<void> => {
|
|
151
|
-
if (IS_DEV || !Updates.isEnabled) {
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
setStatus("downloading");
|
|
157
|
-
setProgress(0);
|
|
158
|
-
|
|
159
|
-
const progressInterval = setInterval(() => {
|
|
160
|
-
setProgress((p) => Math.min(p + 10, 90));
|
|
161
|
-
}, 200);
|
|
162
|
-
|
|
163
|
-
await Updates.fetchUpdateAsync();
|
|
164
|
-
|
|
165
|
-
clearInterval(progressInterval);
|
|
166
|
-
setProgress(100);
|
|
167
|
-
setStatus("ready");
|
|
168
|
-
onUpdateReady?.();
|
|
169
|
-
} catch (e) {
|
|
170
|
-
const err = e as Error;
|
|
171
|
-
setStatus("error");
|
|
172
|
-
setError(err.message);
|
|
173
|
-
onError?.(err);
|
|
174
|
-
}
|
|
175
|
-
}, [onUpdateReady, onError]);
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Apply the downloaded update (restarts the app)
|
|
179
|
-
*/
|
|
180
|
-
const applyUpdate = useCallback(async (): Promise<void> => {
|
|
181
|
-
if (IS_DEV || !Updates.isEnabled) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
await Updates.reloadAsync();
|
|
187
|
-
} catch (e) {
|
|
188
|
-
const err = e as Error;
|
|
189
|
-
setStatus("error");
|
|
190
|
-
setError(err.message);
|
|
191
|
-
onError?.(err);
|
|
192
|
-
}
|
|
193
|
-
}, [onError]);
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Check for available updates
|
|
197
|
-
*/
|
|
198
|
-
const checkForUpdate = useCallback(async (): Promise<boolean> => {
|
|
199
|
-
// Skip in development
|
|
200
|
-
if (IS_DEV || !Updates.isEnabled) {
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
setStatus("checking");
|
|
206
|
-
setError(null);
|
|
207
|
-
|
|
208
|
-
const update = await Updates.checkForUpdateAsync();
|
|
209
|
-
|
|
210
|
-
if (update.isAvailable) {
|
|
211
|
-
setStatus("available");
|
|
212
|
-
setManifest(update.manifest);
|
|
213
|
-
onUpdateAvailable?.(update.manifest);
|
|
214
|
-
|
|
215
|
-
if (showAlert && !autoUpdate) {
|
|
216
|
-
Alert.alert(
|
|
217
|
-
"Update Available",
|
|
218
|
-
"A new version of the app is available. Would you like to update now?",
|
|
219
|
-
[
|
|
220
|
-
{ text: "Later", style: "cancel" },
|
|
221
|
-
{
|
|
222
|
-
text: "Update",
|
|
223
|
-
onPress: () => downloadUpdate(),
|
|
224
|
-
},
|
|
225
|
-
]
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (autoUpdate) {
|
|
230
|
-
await downloadUpdate();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return true;
|
|
234
|
-
} else {
|
|
235
|
-
setStatus("no-update");
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
} catch (e) {
|
|
239
|
-
const err = e as Error;
|
|
240
|
-
setStatus("error");
|
|
241
|
-
setError(err.message);
|
|
242
|
-
onError?.(err);
|
|
243
|
-
return false;
|
|
244
|
-
}
|
|
245
|
-
}, [
|
|
246
|
-
showAlert,
|
|
247
|
-
autoUpdate,
|
|
248
|
-
onUpdateAvailable,
|
|
249
|
-
onError,
|
|
250
|
-
downloadUpdate,
|
|
251
|
-
applyUpdate,
|
|
252
|
-
]);
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Reset update state
|
|
256
|
-
*/
|
|
257
|
-
const reset = useCallback(() => {
|
|
258
|
-
setStatus("idle");
|
|
259
|
-
setProgress(0);
|
|
260
|
-
setError(null);
|
|
261
|
-
setManifest(null);
|
|
262
|
-
}, []);
|
|
263
|
-
|
|
264
|
-
// Check on mount
|
|
265
|
-
useEffect(() => {
|
|
266
|
-
if (checkOnMount) {
|
|
267
|
-
checkForUpdate();
|
|
268
|
-
}
|
|
269
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
270
|
-
}, [checkOnMount]);
|
|
271
|
-
|
|
272
|
-
// Check when app returns to foreground
|
|
273
|
-
useEffect(() => {
|
|
274
|
-
if (!checkOnForeground) return;
|
|
275
|
-
|
|
276
|
-
const handleAppStateChange = (nextState: AppStateStatus) => {
|
|
277
|
-
if (nextState === "active") {
|
|
278
|
-
checkForUpdate();
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const subscription = AppState.addEventListener(
|
|
283
|
-
"change",
|
|
284
|
-
handleAppStateChange
|
|
285
|
-
);
|
|
286
|
-
return () => subscription.remove();
|
|
287
|
-
}, [checkOnForeground, checkForUpdate]);
|
|
288
|
-
|
|
289
|
-
return {
|
|
290
|
-
isAvailable,
|
|
291
|
-
status,
|
|
292
|
-
progress,
|
|
293
|
-
error,
|
|
294
|
-
manifest,
|
|
295
|
-
checkForUpdate,
|
|
296
|
-
downloadUpdate,
|
|
297
|
-
applyUpdate,
|
|
298
|
-
reset,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Get current update info
|
|
304
|
-
*/
|
|
305
|
-
export function getUpdateInfo(): {
|
|
306
|
-
isEnabled: boolean;
|
|
307
|
-
channel: string | null;
|
|
308
|
-
runtimeVersion: string | null;
|
|
309
|
-
updateId: string | null;
|
|
310
|
-
createdAt: Date | null;
|
|
311
|
-
} {
|
|
312
|
-
if (IS_DEV || !Updates.isEnabled) {
|
|
313
|
-
return {
|
|
314
|
-
isEnabled: false,
|
|
315
|
-
channel: null,
|
|
316
|
-
runtimeVersion: null,
|
|
317
|
-
updateId: null,
|
|
318
|
-
createdAt: null,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
return {
|
|
323
|
-
isEnabled: Updates.isEnabled,
|
|
324
|
-
channel: Updates.channel,
|
|
325
|
-
runtimeVersion: Updates.runtimeVersion,
|
|
326
|
-
updateId: Updates.updateId,
|
|
327
|
-
createdAt: Updates.createdAt,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Force check and apply update (useful for settings screen)
|
|
333
|
-
*/
|
|
334
|
-
export async function forceUpdate(): Promise<void> {
|
|
335
|
-
if (IS_DEV || !Updates.isEnabled) {
|
|
336
|
-
Alert.alert(
|
|
337
|
-
"Development Mode",
|
|
338
|
-
"Updates are not available in development."
|
|
339
|
-
);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
try {
|
|
344
|
-
const update = await Updates.checkForUpdateAsync();
|
|
345
|
-
if (update.isAvailable) {
|
|
346
|
-
Alert.alert("Updating...", "Downloading the latest version.");
|
|
347
|
-
await Updates.fetchUpdateAsync();
|
|
348
|
-
await Updates.reloadAsync();
|
|
349
|
-
} else {
|
|
350
|
-
Alert.alert("Up to Date", "You're running the latest version.");
|
|
351
|
-
}
|
|
352
|
-
} catch
|
|
353
|
-
Alert.alert(
|
|
354
|
-
"Update Failed",
|
|
355
|
-
"Could not check for updates. Please try again."
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import * as Updates from "expo-updates";
|
|
3
|
+
import { Alert, AppState, AppStateStatus } from "react-native";
|
|
4
|
+
import { IS_DEV } from "@/constants/config";
|
|
5
|
+
|
|
6
|
+
export type UpdateStatus =
|
|
7
|
+
| "idle"
|
|
8
|
+
| "checking"
|
|
9
|
+
| "available"
|
|
10
|
+
| "downloading"
|
|
11
|
+
| "ready"
|
|
12
|
+
| "error"
|
|
13
|
+
| "no-update";
|
|
14
|
+
|
|
15
|
+
interface UpdateInfo {
|
|
16
|
+
/**
|
|
17
|
+
* Whether an update is available
|
|
18
|
+
*/
|
|
19
|
+
isAvailable: boolean;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Current update status
|
|
23
|
+
*/
|
|
24
|
+
status: UpdateStatus;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Download progress (0-100)
|
|
28
|
+
*/
|
|
29
|
+
progress: number;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Error message if status is 'error'
|
|
33
|
+
*/
|
|
34
|
+
error: string | null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Update manifest info
|
|
38
|
+
*/
|
|
39
|
+
manifest: Updates.Manifest | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface UseUpdatesOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Check for updates on mount
|
|
45
|
+
* @default true
|
|
46
|
+
*/
|
|
47
|
+
checkOnMount?: boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check for updates when app returns to foreground
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
checkOnForeground?: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Show alert when update is available
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
showAlert?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Auto download and apply updates
|
|
63
|
+
* @default false
|
|
64
|
+
*/
|
|
65
|
+
autoUpdate?: boolean;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Callback when update is available
|
|
69
|
+
*/
|
|
70
|
+
onUpdateAvailable?: (manifest: Updates.Manifest) => void;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Callback when update is downloaded and ready
|
|
74
|
+
*/
|
|
75
|
+
onUpdateReady?: () => void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Callback on error
|
|
79
|
+
*/
|
|
80
|
+
onError?: (error: Error) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface UseUpdatesReturn extends UpdateInfo {
|
|
84
|
+
/**
|
|
85
|
+
* Manually check for updates
|
|
86
|
+
*/
|
|
87
|
+
checkForUpdate: () => Promise<boolean>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Download the available update
|
|
91
|
+
*/
|
|
92
|
+
downloadUpdate: () => Promise<void>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Apply the downloaded update (restarts the app)
|
|
96
|
+
*/
|
|
97
|
+
applyUpdate: () => Promise<void>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reset update state
|
|
101
|
+
*/
|
|
102
|
+
reset: () => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Hook for managing OTA updates with expo-updates
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* function App() {
|
|
111
|
+
* const { status, isAvailable, checkForUpdate, applyUpdate } = useUpdates({
|
|
112
|
+
* checkOnMount: true,
|
|
113
|
+
* showAlert: true,
|
|
114
|
+
* });
|
|
115
|
+
*
|
|
116
|
+
* if (status === 'ready') {
|
|
117
|
+
* return (
|
|
118
|
+
* <Button onPress={applyUpdate}>
|
|
119
|
+
* Restart to update
|
|
120
|
+
* </Button>
|
|
121
|
+
* );
|
|
122
|
+
* }
|
|
123
|
+
*
|
|
124
|
+
* return <App />;
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
export function useUpdates(options: UseUpdatesOptions = {}): UseUpdatesReturn {
|
|
129
|
+
const {
|
|
130
|
+
checkOnMount = true,
|
|
131
|
+
checkOnForeground = true,
|
|
132
|
+
showAlert = true,
|
|
133
|
+
autoUpdate = false,
|
|
134
|
+
onUpdateAvailable,
|
|
135
|
+
onUpdateReady,
|
|
136
|
+
onError,
|
|
137
|
+
} = options;
|
|
138
|
+
|
|
139
|
+
const [status, setStatus] = useState<UpdateStatus>("idle");
|
|
140
|
+
const [progress, setProgress] = useState(0);
|
|
141
|
+
const [error, setError] = useState<string | null>(null);
|
|
142
|
+
const [manifest, setManifest] = useState<Updates.Manifest | null>(null);
|
|
143
|
+
|
|
144
|
+
const isAvailable =
|
|
145
|
+
status === "available" || status === "downloading" || status === "ready";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Download the available update
|
|
149
|
+
*/
|
|
150
|
+
const downloadUpdate = useCallback(async (): Promise<void> => {
|
|
151
|
+
if (IS_DEV || !Updates.isEnabled) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
setStatus("downloading");
|
|
157
|
+
setProgress(0);
|
|
158
|
+
|
|
159
|
+
const progressInterval = setInterval(() => {
|
|
160
|
+
setProgress((p) => Math.min(p + 10, 90));
|
|
161
|
+
}, 200);
|
|
162
|
+
|
|
163
|
+
await Updates.fetchUpdateAsync();
|
|
164
|
+
|
|
165
|
+
clearInterval(progressInterval);
|
|
166
|
+
setProgress(100);
|
|
167
|
+
setStatus("ready");
|
|
168
|
+
onUpdateReady?.();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const err = e as Error;
|
|
171
|
+
setStatus("error");
|
|
172
|
+
setError(err.message);
|
|
173
|
+
onError?.(err);
|
|
174
|
+
}
|
|
175
|
+
}, [onUpdateReady, onError]);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Apply the downloaded update (restarts the app)
|
|
179
|
+
*/
|
|
180
|
+
const applyUpdate = useCallback(async (): Promise<void> => {
|
|
181
|
+
if (IS_DEV || !Updates.isEnabled) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await Updates.reloadAsync();
|
|
187
|
+
} catch (e) {
|
|
188
|
+
const err = e as Error;
|
|
189
|
+
setStatus("error");
|
|
190
|
+
setError(err.message);
|
|
191
|
+
onError?.(err);
|
|
192
|
+
}
|
|
193
|
+
}, [onError]);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check for available updates
|
|
197
|
+
*/
|
|
198
|
+
const checkForUpdate = useCallback(async (): Promise<boolean> => {
|
|
199
|
+
// Skip in development
|
|
200
|
+
if (IS_DEV || !Updates.isEnabled) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
setStatus("checking");
|
|
206
|
+
setError(null);
|
|
207
|
+
|
|
208
|
+
const update = await Updates.checkForUpdateAsync();
|
|
209
|
+
|
|
210
|
+
if (update.isAvailable) {
|
|
211
|
+
setStatus("available");
|
|
212
|
+
setManifest(update.manifest);
|
|
213
|
+
onUpdateAvailable?.(update.manifest);
|
|
214
|
+
|
|
215
|
+
if (showAlert && !autoUpdate) {
|
|
216
|
+
Alert.alert(
|
|
217
|
+
"Update Available",
|
|
218
|
+
"A new version of the app is available. Would you like to update now?",
|
|
219
|
+
[
|
|
220
|
+
{ text: "Later", style: "cancel" },
|
|
221
|
+
{
|
|
222
|
+
text: "Update",
|
|
223
|
+
onPress: () => downloadUpdate(),
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (autoUpdate) {
|
|
230
|
+
await downloadUpdate();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return true;
|
|
234
|
+
} else {
|
|
235
|
+
setStatus("no-update");
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
239
|
+
const err = e as Error;
|
|
240
|
+
setStatus("error");
|
|
241
|
+
setError(err.message);
|
|
242
|
+
onError?.(err);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}, [
|
|
246
|
+
showAlert,
|
|
247
|
+
autoUpdate,
|
|
248
|
+
onUpdateAvailable,
|
|
249
|
+
onError,
|
|
250
|
+
downloadUpdate,
|
|
251
|
+
applyUpdate,
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Reset update state
|
|
256
|
+
*/
|
|
257
|
+
const reset = useCallback(() => {
|
|
258
|
+
setStatus("idle");
|
|
259
|
+
setProgress(0);
|
|
260
|
+
setError(null);
|
|
261
|
+
setManifest(null);
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
// Check on mount
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (checkOnMount) {
|
|
267
|
+
checkForUpdate();
|
|
268
|
+
}
|
|
269
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
270
|
+
}, [checkOnMount]);
|
|
271
|
+
|
|
272
|
+
// Check when app returns to foreground
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!checkOnForeground) return;
|
|
275
|
+
|
|
276
|
+
const handleAppStateChange = (nextState: AppStateStatus) => {
|
|
277
|
+
if (nextState === "active") {
|
|
278
|
+
checkForUpdate();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const subscription = AppState.addEventListener(
|
|
283
|
+
"change",
|
|
284
|
+
handleAppStateChange
|
|
285
|
+
);
|
|
286
|
+
return () => subscription.remove();
|
|
287
|
+
}, [checkOnForeground, checkForUpdate]);
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
isAvailable,
|
|
291
|
+
status,
|
|
292
|
+
progress,
|
|
293
|
+
error,
|
|
294
|
+
manifest,
|
|
295
|
+
checkForUpdate,
|
|
296
|
+
downloadUpdate,
|
|
297
|
+
applyUpdate,
|
|
298
|
+
reset,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get current update info
|
|
304
|
+
*/
|
|
305
|
+
export function getUpdateInfo(): {
|
|
306
|
+
isEnabled: boolean;
|
|
307
|
+
channel: string | null;
|
|
308
|
+
runtimeVersion: string | null;
|
|
309
|
+
updateId: string | null;
|
|
310
|
+
createdAt: Date | null;
|
|
311
|
+
} {
|
|
312
|
+
if (IS_DEV || !Updates.isEnabled) {
|
|
313
|
+
return {
|
|
314
|
+
isEnabled: false,
|
|
315
|
+
channel: null,
|
|
316
|
+
runtimeVersion: null,
|
|
317
|
+
updateId: null,
|
|
318
|
+
createdAt: null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
isEnabled: Updates.isEnabled,
|
|
324
|
+
channel: Updates.channel,
|
|
325
|
+
runtimeVersion: Updates.runtimeVersion,
|
|
326
|
+
updateId: Updates.updateId,
|
|
327
|
+
createdAt: Updates.createdAt,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Force check and apply update (useful for settings screen)
|
|
333
|
+
*/
|
|
334
|
+
export async function forceUpdate(): Promise<void> {
|
|
335
|
+
if (IS_DEV || !Updates.isEnabled) {
|
|
336
|
+
Alert.alert(
|
|
337
|
+
"Development Mode",
|
|
338
|
+
"Updates are not available in development."
|
|
339
|
+
);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const update = await Updates.checkForUpdateAsync();
|
|
345
|
+
if (update.isAvailable) {
|
|
346
|
+
Alert.alert("Updating...", "Downloading the latest version.");
|
|
347
|
+
await Updates.fetchUpdateAsync();
|
|
348
|
+
await Updates.reloadAsync();
|
|
349
|
+
} else {
|
|
350
|
+
Alert.alert("Up to Date", "You're running the latest version.");
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
Alert.alert(
|
|
354
|
+
"Update Failed",
|
|
355
|
+
"Could not check for updates. Please try again."
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|