@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
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @fileoverview React hooks for feature flag evaluation
3
+ * Provides `useFeatureFlag` for boolean checks and `useFeatureFlagValue`
4
+ * for typed flag values, both backed by the FeatureFlags facade.
5
+ * @module hooks/useFeatureFlag
6
+ */
7
+
8
+ import { useState, useEffect } from "react";
9
+ import { FeatureFlags } from "@/services/feature-flags/feature-flag-adapter";
10
+
11
+ /**
12
+ * Hook that resolves a boolean feature flag.
13
+ *
14
+ * @param flag - The flag key to evaluate
15
+ * @param defaultValue - Value used until the flag is resolved (default `false`)
16
+ * @returns `{ isEnabled, isLoading }`
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const { isEnabled, isLoading } = useFeatureFlag("new_checkout");
21
+ * if (isLoading) return <Loader />;
22
+ * return isEnabled ? <NewCheckout /> : <OldCheckout />;
23
+ * ```
24
+ */
25
+ export function useFeatureFlag(flag: string, defaultValue = false) {
26
+ const [isEnabled, setIsEnabled] = useState(defaultValue);
27
+ const [isLoading, setIsLoading] = useState(true);
28
+
29
+ useEffect(() => {
30
+ setIsEnabled(FeatureFlags.isEnabled(flag, defaultValue));
31
+ setIsLoading(false);
32
+ }, [flag, defaultValue]);
33
+
34
+ return { isEnabled, isLoading };
35
+ }
36
+
37
+ /**
38
+ * Hook that resolves a feature flag with an arbitrary type.
39
+ *
40
+ * @param flag - The flag key to evaluate
41
+ * @param defaultValue - Value used until the flag is resolved
42
+ * @returns `{ value, isLoading }`
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * const { value: maxItems } = useFeatureFlagValue("max_cart_items", 10);
47
+ * ```
48
+ */
49
+ export function useFeatureFlagValue<T>(flag: string, defaultValue: T) {
50
+ const [value, setValue] = useState<T>(defaultValue);
51
+ const [isLoading, setIsLoading] = useState(true);
52
+
53
+ useEffect(() => {
54
+ setValue(FeatureFlags.getValue(flag, defaultValue));
55
+ setIsLoading(false);
56
+ }, [flag, defaultValue]);
57
+
58
+ return { value, isLoading };
59
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @fileoverview Hook for app force-update checks
3
+ * Checks on mount whether the running app version satisfies the server's
4
+ * minimum version requirement and exposes the result to the UI.
5
+ * @module hooks/useForceUpdate
6
+ */
7
+
8
+ import { useEffect, useState } from "react";
9
+ import Constants from "expo-constants";
10
+
11
+ import { FORCE_UPDATE } from "@/constants/config";
12
+ import { checkForUpdate } from "@/services/force-update";
13
+ import type { ForceUpdateResult } from "@/services/force-update";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export interface UseForceUpdateReturn extends ForceUpdateResult {
20
+ /** Whether the check is still in progress */
21
+ isChecking: boolean;
22
+ }
23
+
24
+ // ============================================================================
25
+ // Hook
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Check on mount whether the app requires a mandatory native update.
30
+ *
31
+ * Uses `Constants.expoConfig?.version` as the current version and the
32
+ * `FORCE_UPDATE` configuration from `constants/config.ts`.
33
+ *
34
+ * When `FORCE_UPDATE.ENABLED` is `false` or `FORCE_UPDATE.CHECK_URL` is
35
+ * empty the hook returns immediately with `isUpdateRequired: false`.
36
+ *
37
+ * @returns Force-update state including loading indicator
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * function App() {
42
+ * const { isUpdateRequired, isChecking, storeUrl, currentVersion, minimumVersion } =
43
+ * useForceUpdate();
44
+ *
45
+ * if (isChecking) return <SplashScreen />;
46
+ *
47
+ * if (isUpdateRequired) {
48
+ * return (
49
+ * <ForceUpdateScreen
50
+ * storeUrl={storeUrl}
51
+ * currentVersion={currentVersion}
52
+ * minimumVersion={minimumVersion}
53
+ * />
54
+ * );
55
+ * }
56
+ *
57
+ * return <MainApp />;
58
+ * }
59
+ * ```
60
+ */
61
+ export function useForceUpdate(): UseForceUpdateReturn {
62
+ const [isChecking, setIsChecking] = useState(true);
63
+ const [result, setResult] = useState<ForceUpdateResult>({
64
+ isUpdateRequired: false,
65
+ currentVersion: "",
66
+ minimumVersion: "",
67
+ storeUrl: "",
68
+ });
69
+
70
+ useEffect(() => {
71
+ // Skip the check entirely when force-update is disabled or unconfigured
72
+ if (!FORCE_UPDATE.ENABLED || !FORCE_UPDATE.CHECK_URL) {
73
+ setIsChecking(false);
74
+ return;
75
+ }
76
+
77
+ const currentVersion = Constants.expoConfig?.version ?? "1.0.0";
78
+
79
+ checkForUpdate({
80
+ checkUrl: FORCE_UPDATE.CHECK_URL,
81
+ currentVersion,
82
+ })
83
+ .then(setResult)
84
+ .finally(() => setIsChecking(false));
85
+ }, []);
86
+
87
+ return {
88
+ ...result,
89
+ isChecking,
90
+ };
91
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @fileoverview Combined image pick, compress, and preview hook
3
+ * Integrates media picker and compression services with permission management.
4
+ * @module hooks/useImagePicker
5
+ */
6
+
7
+ import { useState, useCallback } from "react";
8
+ import { Alert, Platform } from "react-native";
9
+
10
+ import { usePermission } from "@/hooks/usePermission";
11
+ import {
12
+ pickFromLibrary as pickFromLib,
13
+ pickFromCamera as pickFromCam,
14
+ type PickedMedia,
15
+ } from "@/services/media/media-picker";
16
+ import { compressImage } from "@/services/media/compression";
17
+
18
+ /**
19
+ * Options for the useImagePicker hook
20
+ */
21
+ export interface UseImagePickerOptions {
22
+ /** Whether to compress images after picking (default: true) */
23
+ compress?: boolean;
24
+ /** Maximum width for compression (default: 1080) */
25
+ maxWidth?: number;
26
+ /** Maximum height for compression (default: 1080) */
27
+ maxHeight?: number;
28
+ /** Compression quality 0-1 (default: 0.7) */
29
+ quality?: number;
30
+ }
31
+
32
+ /**
33
+ * Return type for the useImagePicker hook
34
+ */
35
+ export interface UseImagePickerReturn {
36
+ /** Pick media from the photo library */
37
+ pickFromLibrary: () => Promise<PickedMedia | null>;
38
+ /** Take a photo with the camera */
39
+ pickFromCamera: () => Promise<PickedMedia | null>;
40
+ /** Currently selected media item */
41
+ selectedMedia: PickedMedia | null;
42
+ /** Whether a pick or compress operation is in progress */
43
+ isLoading: boolean;
44
+ /** Clear the selected media */
45
+ clear: () => void;
46
+ /** Camera permission state from usePermission */
47
+ cameraPermission: ReturnType<typeof usePermission>;
48
+ /** Media library permission state from usePermission */
49
+ mediaLibraryPermission: ReturnType<typeof usePermission>;
50
+ }
51
+
52
+ /**
53
+ * Hook for picking, compressing, and previewing images.
54
+ * Combines the media picker service with compression and centralized
55
+ * permission management via usePermission.
56
+ *
57
+ * @param options - Configuration for compression and behavior
58
+ * @returns Pick functions, selected media state, and permission info
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * function ProfilePhoto() {
63
+ * const { pickFromLibrary, pickFromCamera, selectedMedia, isLoading, clear } =
64
+ * useImagePicker({ maxWidth: 500, quality: 0.8 });
65
+ *
66
+ * return (
67
+ * <View>
68
+ * {selectedMedia ? (
69
+ * <>
70
+ * <Image source={{ uri: selectedMedia.uri }} style={{ width: 200, height: 200 }} />
71
+ * <Button onPress={clear}>Remove</Button>
72
+ * </>
73
+ * ) : (
74
+ * <>
75
+ * <Button onPress={pickFromLibrary}>Choose Photo</Button>
76
+ * <Button onPress={pickFromCamera}>Take Photo</Button>
77
+ * </>
78
+ * )}
79
+ * {isLoading && <ActivityIndicator />}
80
+ * </View>
81
+ * );
82
+ * }
83
+ * ```
84
+ */
85
+ export function useImagePicker(
86
+ options: UseImagePickerOptions = {}
87
+ ): UseImagePickerReturn {
88
+ const {
89
+ compress = true,
90
+ maxWidth = 1080,
91
+ maxHeight = 1080,
92
+ quality = 0.7,
93
+ } = options;
94
+
95
+ const [selectedMedia, setSelectedMedia] = useState<PickedMedia | null>(null);
96
+ const [isLoading, setIsLoading] = useState(false);
97
+
98
+ const cameraPermission = usePermission("camera");
99
+ const mediaLibraryPermission = usePermission("mediaLibrary");
100
+
101
+ /**
102
+ * Ensure a permission is granted, requesting it if needed
103
+ */
104
+ const ensurePermission = useCallback(
105
+ async (
106
+ permission: ReturnType<typeof usePermission>,
107
+ label: string
108
+ ): Promise<boolean> => {
109
+ if (permission.isGranted) {
110
+ return true;
111
+ }
112
+
113
+ if (permission.isBlocked) {
114
+ Alert.alert(
115
+ `${label} Permission Required`,
116
+ `Please allow ${label.toLowerCase()} access in your device settings.`,
117
+ [
118
+ { text: "Cancel", style: "cancel" },
119
+ { text: "Open Settings", onPress: () => permission.openSettings() },
120
+ ]
121
+ );
122
+ return false;
123
+ }
124
+
125
+ // Request the permission and use the returned status directly
126
+ // to avoid reading stale React state after an async operation.
127
+ const resultStatus = await permission.request();
128
+ return resultStatus === "granted";
129
+ },
130
+ []
131
+ );
132
+
133
+ /**
134
+ * Optionally compress a picked image
135
+ */
136
+ const maybeCompress = useCallback(
137
+ async (media: PickedMedia): Promise<PickedMedia> => {
138
+ if (!compress || media.type !== "image") {
139
+ return media;
140
+ }
141
+
142
+ try {
143
+ const result = await compressImage(media.uri, {
144
+ maxWidth,
145
+ maxHeight,
146
+ quality,
147
+ });
148
+
149
+ return {
150
+ ...media,
151
+ uri: result.uri,
152
+ width: result.width,
153
+ height: result.height,
154
+ };
155
+ } catch (err) {
156
+ console.warn(
157
+ "[useImagePicker] Compression failed, using original:",
158
+ err
159
+ );
160
+ return media;
161
+ }
162
+ },
163
+ [compress, maxWidth, maxHeight, quality]
164
+ );
165
+
166
+ /**
167
+ * Pick media from the photo library
168
+ */
169
+ const pickFromLibrary = useCallback(async (): Promise<PickedMedia | null> => {
170
+ setIsLoading(true);
171
+ try {
172
+ const hasPermission = await ensurePermission(
173
+ mediaLibraryPermission,
174
+ "Photo Library"
175
+ );
176
+ if (!hasPermission) {
177
+ return null;
178
+ }
179
+
180
+ const items = await pickFromLib({ quality: 0.8 });
181
+ if (items.length === 0) {
182
+ return null;
183
+ }
184
+
185
+ const compressed = await maybeCompress(items[0]);
186
+ setSelectedMedia(compressed);
187
+ return compressed;
188
+ } catch (err) {
189
+ console.error("[useImagePicker] Library pick error:", err);
190
+ return null;
191
+ } finally {
192
+ setIsLoading(false);
193
+ }
194
+ }, [ensurePermission, mediaLibraryPermission, maybeCompress]);
195
+
196
+ /**
197
+ * Take a photo with the camera
198
+ */
199
+ const pickFromCamera = useCallback(async (): Promise<PickedMedia | null> => {
200
+ setIsLoading(true);
201
+ try {
202
+ const hasPermission = await ensurePermission(cameraPermission, "Camera");
203
+ if (!hasPermission) {
204
+ return null;
205
+ }
206
+
207
+ const item = await pickFromCam({ quality: 0.8 });
208
+ if (!item) {
209
+ return null;
210
+ }
211
+
212
+ const compressed = await maybeCompress(item);
213
+ setSelectedMedia(compressed);
214
+ return compressed;
215
+ } catch (err) {
216
+ console.error("[useImagePicker] Camera pick error:", err);
217
+ return null;
218
+ } finally {
219
+ setIsLoading(false);
220
+ }
221
+ }, [ensurePermission, cameraPermission, maybeCompress]);
222
+
223
+ /**
224
+ * Clear the selected media
225
+ */
226
+ const clear = useCallback(() => {
227
+ setSelectedMedia(null);
228
+ }, []);
229
+
230
+ return {
231
+ pickFromLibrary,
232
+ pickFromCamera,
233
+ selectedMedia,
234
+ isLoading,
235
+ clear,
236
+ cameraPermission,
237
+ mediaLibraryPermission,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Utility to get file extension from URI
243
+ */
244
+ export function getFileExtension(uri: string): string {
245
+ const match = uri.match(/\.(\w+)$/);
246
+ return match ? match[1].toLowerCase() : "jpg";
247
+ }
248
+
249
+ /**
250
+ * Utility to get MIME type from extension
251
+ */
252
+ export function getMimeType(extension: string): string {
253
+ const mimeTypes: Record<string, string> = {
254
+ jpg: "image/jpeg",
255
+ jpeg: "image/jpeg",
256
+ png: "image/png",
257
+ gif: "image/gif",
258
+ webp: "image/webp",
259
+ heic: "image/heic",
260
+ heif: "image/heif",
261
+ };
262
+ return mimeTypes[extension.toLowerCase()] || "image/jpeg";
263
+ }
264
+
265
+ /**
266
+ * Prepare image for FormData upload
267
+ */
268
+ export function prepareImageForUpload(
269
+ media: PickedMedia,
270
+ fieldName = "image"
271
+ ): { uri: string; type: string; name: string } {
272
+ const extension = getFileExtension(media.uri);
273
+ const type = getMimeType(extension);
274
+ const name = media.fileName || `${fieldName}.${extension}`;
275
+
276
+ return {
277
+ uri: Platform.OS === "ios" ? media.uri.replace("file://", "") : media.uri,
278
+ type,
279
+ name,
280
+ };
281
+ }
@@ -0,0 +1,64 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import * as StoreReview from "expo-store-review";
3
+ import { storage } from "@/services/storage";
4
+ import { IN_APP_REVIEW } from "@/constants/config";
5
+ import { useAppStore } from "@/stores/appStore";
6
+
7
+ const LAST_REVIEW_PROMPT_DATE_KEY = "LAST_REVIEW_PROMPT_DATE";
8
+
9
+ interface UseInAppReviewReturn {
10
+ requestReview: () => Promise<void>;
11
+ isAvailable: boolean;
12
+ hasRequested: boolean;
13
+ }
14
+
15
+ /**
16
+ * Hook for requesting in-app reviews with throttling and session gating.
17
+ *
18
+ * - Won't show if fewer than MIN_SESSIONS sessions have occurred
19
+ * - Won't show if the last prompt was less than DAYS_BETWEEN_PROMPTS days ago
20
+ * - Tracks whether a review was requested in this session
21
+ */
22
+ export function useInAppReview(): UseInAppReviewReturn {
23
+ const [isAvailable, setIsAvailable] = useState(false);
24
+ const [hasRequested, setHasRequested] = useState(false);
25
+ const sessionCount = useAppStore((s) => s.sessionCount);
26
+
27
+ useEffect(() => {
28
+ StoreReview.isAvailableAsync()
29
+ .then(setIsAvailable)
30
+ .catch(() => setIsAvailable(false));
31
+ }, []);
32
+
33
+ const requestReview = useCallback(async () => {
34
+ // Already requested this session
35
+ if (hasRequested) return;
36
+
37
+ // Platform doesn't support in-app review
38
+ if (!isAvailable) return;
39
+
40
+ // Not enough sessions yet
41
+ if (sessionCount < IN_APP_REVIEW.MIN_SESSIONS) return;
42
+
43
+ // Check throttle window
44
+ const lastPromptDate = await storage.get<string>(
45
+ LAST_REVIEW_PROMPT_DATE_KEY
46
+ );
47
+ if (lastPromptDate) {
48
+ const daysSinceLastPrompt =
49
+ (Date.now() - new Date(lastPromptDate).getTime()) /
50
+ (1000 * 60 * 60 * 24);
51
+ if (daysSinceLastPrompt < IN_APP_REVIEW.DAYS_BETWEEN_PROMPTS) return;
52
+ }
53
+
54
+ try {
55
+ await StoreReview.requestReview();
56
+ setHasRequested(true);
57
+ await storage.set(LAST_REVIEW_PROMPT_DATE_KEY, new Date().toISOString());
58
+ } catch (error) {
59
+ console.error("Failed to request in-app review:", error);
60
+ }
61
+ }, [hasRequested, isAvailable, sessionCount]);
62
+
63
+ return { requestReview, isAvailable, hasRequested };
64
+ }