@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
|
@@ -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
|
+
}
|