@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,3222 @@
|
|
|
1
|
+
# Phase 6: Template Completion — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Complete the React Native template with 7 feature domains: permissions, animations, social login, analytics, payments, file upload, and websockets.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Follows existing patterns — adapter pattern (like `services/auth/authAdapter.ts`) for analytics & payments, turnkey hooks+components for permissions/animations/social login, lightweight hooks for uploads/websockets. All new services live under `services/`, hooks under `hooks/`, components under `components/`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Expo SDK 52, React Native 0.76, TypeScript, Zustand, React Query, NativeWind, Reanimated 3, Zod
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Install all new dependencies
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `package.json`
|
|
17
|
+
- Modify: `app.config.ts`
|
|
18
|
+
|
|
19
|
+
**Step 1: Install Expo packages**
|
|
20
|
+
|
|
21
|
+
Run:
|
|
22
|
+
```bash
|
|
23
|
+
npx expo install expo-camera expo-location expo-contacts expo-media-library expo-auth-session expo-web-browser expo-apple-authentication expo-crypto expo-image-manipulator
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Expected: All packages installed with Expo SDK 52 compatible versions.
|
|
27
|
+
|
|
28
|
+
**Step 2: Verify installation**
|
|
29
|
+
|
|
30
|
+
Run:
|
|
31
|
+
```bash
|
|
32
|
+
npx expo-doctor
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Expected: No critical errors.
|
|
36
|
+
|
|
37
|
+
**Step 3: Commit**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git add package.json package-lock.json
|
|
41
|
+
git commit -m "chore: install Phase 6 dependencies (permissions, social login, media)"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Task 2: Permission Management — Types & Service
|
|
47
|
+
|
|
48
|
+
**Files:**
|
|
49
|
+
- Create: `services/permissions/types.ts`
|
|
50
|
+
- Create: `services/permissions/permission-manager.ts`
|
|
51
|
+
|
|
52
|
+
**Step 1: Create permission types**
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
// services/permissions/types.ts
|
|
56
|
+
import * as Camera from 'expo-camera';
|
|
57
|
+
import * as Location from 'expo-location';
|
|
58
|
+
import * as Contacts from 'expo-contacts';
|
|
59
|
+
import * as MediaLibrary from 'expo-media-library';
|
|
60
|
+
import * as Notifications from 'expo-notifications';
|
|
61
|
+
|
|
62
|
+
export type PermissionType =
|
|
63
|
+
| 'camera'
|
|
64
|
+
| 'location'
|
|
65
|
+
| 'locationAlways'
|
|
66
|
+
| 'contacts'
|
|
67
|
+
| 'mediaLibrary'
|
|
68
|
+
| 'microphone'
|
|
69
|
+
| 'notifications';
|
|
70
|
+
|
|
71
|
+
export type PermissionStatus = 'undetermined' | 'granted' | 'denied' | 'blocked';
|
|
72
|
+
|
|
73
|
+
export interface PermissionResult {
|
|
74
|
+
status: PermissionStatus;
|
|
75
|
+
canAskAgain: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PermissionConfig {
|
|
79
|
+
/** Title shown in the rationale dialog */
|
|
80
|
+
title: string;
|
|
81
|
+
/** Message explaining why the permission is needed */
|
|
82
|
+
message: string;
|
|
83
|
+
/** Icon name from @expo/vector-icons */
|
|
84
|
+
icon?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_PERMISSION_CONFIGS: Record<PermissionType, PermissionConfig> = {
|
|
88
|
+
camera: {
|
|
89
|
+
title: 'Camera Access',
|
|
90
|
+
message: 'We need access to your camera to take photos.',
|
|
91
|
+
icon: 'camera',
|
|
92
|
+
},
|
|
93
|
+
location: {
|
|
94
|
+
title: 'Location Access',
|
|
95
|
+
message: 'We need your location to show nearby results.',
|
|
96
|
+
icon: 'location',
|
|
97
|
+
},
|
|
98
|
+
locationAlways: {
|
|
99
|
+
title: 'Background Location',
|
|
100
|
+
message: 'We need background location access for tracking.',
|
|
101
|
+
icon: 'location',
|
|
102
|
+
},
|
|
103
|
+
contacts: {
|
|
104
|
+
title: 'Contacts Access',
|
|
105
|
+
message: 'We need access to your contacts to find friends.',
|
|
106
|
+
icon: 'people',
|
|
107
|
+
},
|
|
108
|
+
mediaLibrary: {
|
|
109
|
+
title: 'Photo Library',
|
|
110
|
+
message: 'We need access to your photo library to select images.',
|
|
111
|
+
icon: 'images',
|
|
112
|
+
},
|
|
113
|
+
microphone: {
|
|
114
|
+
title: 'Microphone Access',
|
|
115
|
+
message: 'We need microphone access to record audio.',
|
|
116
|
+
icon: 'mic',
|
|
117
|
+
},
|
|
118
|
+
notifications: {
|
|
119
|
+
title: 'Notifications',
|
|
120
|
+
message: 'We need permission to send you notifications.',
|
|
121
|
+
icon: 'notifications',
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Step 2: Create permission manager service**
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// services/permissions/permission-manager.ts
|
|
130
|
+
import * as Camera from 'expo-camera';
|
|
131
|
+
import * as Location from 'expo-location';
|
|
132
|
+
import * as Contacts from 'expo-contacts';
|
|
133
|
+
import * as MediaLibrary from 'expo-media-library';
|
|
134
|
+
import * as Notifications from 'expo-notifications';
|
|
135
|
+
import { Linking, Platform } from 'react-native';
|
|
136
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
137
|
+
import { PermissionType, PermissionResult, PermissionStatus } from './types';
|
|
138
|
+
|
|
139
|
+
const PERMISSION_STORAGE_PREFIX = '@permission_asked_';
|
|
140
|
+
|
|
141
|
+
const normalizeStatus = (
|
|
142
|
+
status: string,
|
|
143
|
+
canAskAgain: boolean
|
|
144
|
+
): PermissionStatus => {
|
|
145
|
+
if (status === 'granted') return 'granted';
|
|
146
|
+
if (status === 'denied' && !canAskAgain) return 'blocked';
|
|
147
|
+
if (status === 'denied') return 'denied';
|
|
148
|
+
return 'undetermined';
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const permissionHandlers: Record<
|
|
152
|
+
PermissionType,
|
|
153
|
+
{
|
|
154
|
+
check: () => Promise<PermissionResult>;
|
|
155
|
+
request: () => Promise<PermissionResult>;
|
|
156
|
+
}
|
|
157
|
+
> = {
|
|
158
|
+
camera: {
|
|
159
|
+
check: async () => {
|
|
160
|
+
const { status, canAskAgain } = await Camera.getCameraPermissionsAsync();
|
|
161
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
162
|
+
},
|
|
163
|
+
request: async () => {
|
|
164
|
+
const { status, canAskAgain } = await Camera.requestCameraPermissionsAsync();
|
|
165
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
microphone: {
|
|
169
|
+
check: async () => {
|
|
170
|
+
const { status, canAskAgain } = await Camera.getMicrophonePermissionsAsync();
|
|
171
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
172
|
+
},
|
|
173
|
+
request: async () => {
|
|
174
|
+
const { status, canAskAgain } = await Camera.requestMicrophonePermissionsAsync();
|
|
175
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
location: {
|
|
179
|
+
check: async () => {
|
|
180
|
+
const { status, canAskAgain } = await Location.getForegroundPermissionsAsync();
|
|
181
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
182
|
+
},
|
|
183
|
+
request: async () => {
|
|
184
|
+
const { status, canAskAgain } = await Location.requestForegroundPermissionsAsync();
|
|
185
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
locationAlways: {
|
|
189
|
+
check: async () => {
|
|
190
|
+
const { status, canAskAgain } = await Location.getBackgroundPermissionsAsync();
|
|
191
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
192
|
+
},
|
|
193
|
+
request: async () => {
|
|
194
|
+
const { status, canAskAgain } = await Location.requestBackgroundPermissionsAsync();
|
|
195
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
contacts: {
|
|
199
|
+
check: async () => {
|
|
200
|
+
const { status, canAskAgain } = await Contacts.getPermissionsAsync();
|
|
201
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
202
|
+
},
|
|
203
|
+
request: async () => {
|
|
204
|
+
const { status, canAskAgain } = await Contacts.requestPermissionsAsync();
|
|
205
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
mediaLibrary: {
|
|
209
|
+
check: async () => {
|
|
210
|
+
const { status, canAskAgain } = await MediaLibrary.getPermissionsAsync();
|
|
211
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
212
|
+
},
|
|
213
|
+
request: async () => {
|
|
214
|
+
const { status, canAskAgain } = await MediaLibrary.requestPermissionsAsync();
|
|
215
|
+
return { status: normalizeStatus(status, canAskAgain), canAskAgain };
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
notifications: {
|
|
219
|
+
check: async () => {
|
|
220
|
+
const settings = await Notifications.getPermissionsAsync();
|
|
221
|
+
return {
|
|
222
|
+
status: normalizeStatus(settings.status, settings.canAskAgain),
|
|
223
|
+
canAskAgain: settings.canAskAgain,
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
request: async () => {
|
|
227
|
+
const settings = await Notifications.requestPermissionsAsync();
|
|
228
|
+
return {
|
|
229
|
+
status: normalizeStatus(settings.status, settings.canAskAgain),
|
|
230
|
+
canAskAgain: settings.canAskAgain,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const PermissionManager = {
|
|
237
|
+
check: async (type: PermissionType): Promise<PermissionResult> => {
|
|
238
|
+
return permissionHandlers[type].check();
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
request: async (type: PermissionType): Promise<PermissionResult> => {
|
|
242
|
+
await AsyncStorage.setItem(`${PERMISSION_STORAGE_PREFIX}${type}`, 'true');
|
|
243
|
+
return permissionHandlers[type].request();
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
openSettings: async (): Promise<void> => {
|
|
247
|
+
if (Platform.OS === 'ios') {
|
|
248
|
+
await Linking.openURL('app-settings:');
|
|
249
|
+
} else {
|
|
250
|
+
await Linking.openSettings();
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
hasBeenAsked: async (type: PermissionType): Promise<boolean> => {
|
|
255
|
+
const value = await AsyncStorage.getItem(`${PERMISSION_STORAGE_PREFIX}${type}`);
|
|
256
|
+
return value === 'true';
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Step 3: Commit**
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
git add services/permissions/
|
|
265
|
+
git commit -m "feat(permissions): add permission types and centralized manager service"
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Task 3: Permission Management — Hook & Component
|
|
271
|
+
|
|
272
|
+
**Files:**
|
|
273
|
+
- Create: `hooks/usePermission.ts`
|
|
274
|
+
- Create: `components/ui/PermissionGate.tsx`
|
|
275
|
+
|
|
276
|
+
**Step 1: Create usePermission hook**
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
// hooks/usePermission.ts
|
|
280
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
281
|
+
import { AppState, AppStateStatus } from 'react-native';
|
|
282
|
+
import { PermissionManager } from '@/services/permissions/permission-manager';
|
|
283
|
+
import {
|
|
284
|
+
PermissionType,
|
|
285
|
+
PermissionStatus,
|
|
286
|
+
PermissionConfig,
|
|
287
|
+
DEFAULT_PERMISSION_CONFIGS,
|
|
288
|
+
} from '@/services/permissions/types';
|
|
289
|
+
|
|
290
|
+
interface UsePermissionReturn {
|
|
291
|
+
status: PermissionStatus;
|
|
292
|
+
isGranted: boolean;
|
|
293
|
+
isBlocked: boolean;
|
|
294
|
+
isLoading: boolean;
|
|
295
|
+
config: PermissionConfig;
|
|
296
|
+
request: () => Promise<PermissionStatus>;
|
|
297
|
+
openSettings: () => Promise<void>;
|
|
298
|
+
refresh: () => Promise<void>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function usePermission(
|
|
302
|
+
type: PermissionType,
|
|
303
|
+
customConfig?: Partial<PermissionConfig>
|
|
304
|
+
): UsePermissionReturn {
|
|
305
|
+
const [status, setStatus] = useState<PermissionStatus>('undetermined');
|
|
306
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
307
|
+
|
|
308
|
+
const config = {
|
|
309
|
+
...DEFAULT_PERMISSION_CONFIGS[type],
|
|
310
|
+
...customConfig,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const checkPermission = useCallback(async () => {
|
|
314
|
+
const result = await PermissionManager.check(type);
|
|
315
|
+
setStatus(result.status);
|
|
316
|
+
setIsLoading(false);
|
|
317
|
+
}, [type]);
|
|
318
|
+
|
|
319
|
+
const request = useCallback(async (): Promise<PermissionStatus> => {
|
|
320
|
+
setIsLoading(true);
|
|
321
|
+
const result = await PermissionManager.request(type);
|
|
322
|
+
setStatus(result.status);
|
|
323
|
+
setIsLoading(false);
|
|
324
|
+
return result.status;
|
|
325
|
+
}, [type]);
|
|
326
|
+
|
|
327
|
+
const openSettings = useCallback(async () => {
|
|
328
|
+
await PermissionManager.openSettings();
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
// Check on mount
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
checkPermission();
|
|
334
|
+
}, [checkPermission]);
|
|
335
|
+
|
|
336
|
+
// Re-check when app comes back from settings
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
const handleAppStateChange = (nextState: AppStateStatus) => {
|
|
339
|
+
if (nextState === 'active') {
|
|
340
|
+
checkPermission();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
|
344
|
+
return () => subscription.remove();
|
|
345
|
+
}, [checkPermission]);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
status,
|
|
349
|
+
isGranted: status === 'granted',
|
|
350
|
+
isBlocked: status === 'blocked',
|
|
351
|
+
isLoading,
|
|
352
|
+
config,
|
|
353
|
+
request,
|
|
354
|
+
openSettings,
|
|
355
|
+
refresh: checkPermission,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Step 2: Create PermissionGate component**
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
// components/ui/PermissionGate.tsx
|
|
364
|
+
import React from 'react';
|
|
365
|
+
import { View, Text, Pressable } from 'react-native';
|
|
366
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
367
|
+
import { usePermission } from '@/hooks/usePermission';
|
|
368
|
+
import { PermissionType, PermissionConfig } from '@/services/permissions/types';
|
|
369
|
+
import { useTheme } from '@/theme/ThemeContext';
|
|
370
|
+
import { Button } from './Button';
|
|
371
|
+
|
|
372
|
+
interface PermissionGateProps {
|
|
373
|
+
type: PermissionType;
|
|
374
|
+
config?: Partial<PermissionConfig>;
|
|
375
|
+
children: React.ReactNode;
|
|
376
|
+
fallback?: React.ReactNode;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function PermissionGate({
|
|
380
|
+
type,
|
|
381
|
+
config: customConfig,
|
|
382
|
+
children,
|
|
383
|
+
fallback,
|
|
384
|
+
}: PermissionGateProps) {
|
|
385
|
+
const { status, isLoading, isBlocked, config, request, openSettings } =
|
|
386
|
+
usePermission(type, customConfig);
|
|
387
|
+
const { colorScheme } = useTheme();
|
|
388
|
+
const isDark = colorScheme === 'dark';
|
|
389
|
+
|
|
390
|
+
if (isLoading) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (status === 'granted') {
|
|
395
|
+
return <>{children}</>;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (fallback) {
|
|
399
|
+
return <>{fallback}</>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<View className="flex-1 items-center justify-center px-8">
|
|
404
|
+
{config.icon && (
|
|
405
|
+
<Ionicons
|
|
406
|
+
name={config.icon as any}
|
|
407
|
+
size={48}
|
|
408
|
+
color={isDark ? '#9ca3af' : '#6b7280'}
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
411
|
+
<Text
|
|
412
|
+
className="mt-4 text-lg font-semibold text-center text-text-light dark:text-text-dark"
|
|
413
|
+
>
|
|
414
|
+
{config.title}
|
|
415
|
+
</Text>
|
|
416
|
+
<Text
|
|
417
|
+
className="mt-2 text-center text-muted-light dark:text-muted-dark"
|
|
418
|
+
>
|
|
419
|
+
{config.message}
|
|
420
|
+
</Text>
|
|
421
|
+
<View className="mt-6 w-full gap-3">
|
|
422
|
+
{isBlocked ? (
|
|
423
|
+
<Button onPress={openSettings} variant="primary">
|
|
424
|
+
Open Settings
|
|
425
|
+
</Button>
|
|
426
|
+
) : (
|
|
427
|
+
<Button onPress={request} variant="primary">
|
|
428
|
+
Allow Access
|
|
429
|
+
</Button>
|
|
430
|
+
)}
|
|
431
|
+
</View>
|
|
432
|
+
</View>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Step 3: Commit**
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
git add hooks/usePermission.ts components/ui/PermissionGate.tsx
|
|
441
|
+
git commit -m "feat(permissions): add usePermission hook and PermissionGate component"
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Task 4: Permission Management — Tests
|
|
447
|
+
|
|
448
|
+
**Files:**
|
|
449
|
+
- Create: `__tests__/hooks/usePermission.test.tsx`
|
|
450
|
+
|
|
451
|
+
**Step 1: Write tests**
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// __tests__/hooks/usePermission.test.tsx
|
|
455
|
+
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
|
456
|
+
import { usePermission } from '@/hooks/usePermission';
|
|
457
|
+
|
|
458
|
+
// Mock all expo permission modules
|
|
459
|
+
jest.mock('expo-camera', () => ({
|
|
460
|
+
getCameraPermissionsAsync: jest.fn().mockResolvedValue({
|
|
461
|
+
status: 'undetermined',
|
|
462
|
+
canAskAgain: true,
|
|
463
|
+
}),
|
|
464
|
+
requestCameraPermissionsAsync: jest.fn().mockResolvedValue({
|
|
465
|
+
status: 'granted',
|
|
466
|
+
canAskAgain: true,
|
|
467
|
+
}),
|
|
468
|
+
getMicrophonePermissionsAsync: jest.fn().mockResolvedValue({
|
|
469
|
+
status: 'undetermined',
|
|
470
|
+
canAskAgain: true,
|
|
471
|
+
}),
|
|
472
|
+
requestMicrophonePermissionsAsync: jest.fn().mockResolvedValue({
|
|
473
|
+
status: 'granted',
|
|
474
|
+
canAskAgain: true,
|
|
475
|
+
}),
|
|
476
|
+
}));
|
|
477
|
+
|
|
478
|
+
jest.mock('expo-location', () => ({
|
|
479
|
+
getForegroundPermissionsAsync: jest.fn().mockResolvedValue({
|
|
480
|
+
status: 'undetermined',
|
|
481
|
+
canAskAgain: true,
|
|
482
|
+
}),
|
|
483
|
+
requestForegroundPermissionsAsync: jest.fn().mockResolvedValue({
|
|
484
|
+
status: 'granted',
|
|
485
|
+
canAskAgain: true,
|
|
486
|
+
}),
|
|
487
|
+
getBackgroundPermissionsAsync: jest.fn().mockResolvedValue({
|
|
488
|
+
status: 'undetermined',
|
|
489
|
+
canAskAgain: true,
|
|
490
|
+
}),
|
|
491
|
+
requestBackgroundPermissionsAsync: jest.fn().mockResolvedValue({
|
|
492
|
+
status: 'granted',
|
|
493
|
+
canAskAgain: true,
|
|
494
|
+
}),
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
jest.mock('expo-contacts', () => ({
|
|
498
|
+
getPermissionsAsync: jest.fn().mockResolvedValue({
|
|
499
|
+
status: 'undetermined',
|
|
500
|
+
canAskAgain: true,
|
|
501
|
+
}),
|
|
502
|
+
requestPermissionsAsync: jest.fn().mockResolvedValue({
|
|
503
|
+
status: 'granted',
|
|
504
|
+
canAskAgain: true,
|
|
505
|
+
}),
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
jest.mock('expo-media-library', () => ({
|
|
509
|
+
getPermissionsAsync: jest.fn().mockResolvedValue({
|
|
510
|
+
status: 'undetermined',
|
|
511
|
+
canAskAgain: true,
|
|
512
|
+
}),
|
|
513
|
+
requestPermissionsAsync: jest.fn().mockResolvedValue({
|
|
514
|
+
status: 'granted',
|
|
515
|
+
canAskAgain: true,
|
|
516
|
+
}),
|
|
517
|
+
}));
|
|
518
|
+
|
|
519
|
+
describe('usePermission', () => {
|
|
520
|
+
it('should start with undetermined status', async () => {
|
|
521
|
+
const { result } = renderHook(() => usePermission('camera'));
|
|
522
|
+
await waitFor(() => {
|
|
523
|
+
expect(result.current.status).toBe('undetermined');
|
|
524
|
+
expect(result.current.isGranted).toBe(false);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('should request permission and update status', async () => {
|
|
529
|
+
const { result } = renderHook(() => usePermission('camera'));
|
|
530
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
531
|
+
|
|
532
|
+
await act(async () => {
|
|
533
|
+
const status = await result.current.request();
|
|
534
|
+
expect(status).toBe('granted');
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
expect(result.current.isGranted).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('should detect blocked permissions', async () => {
|
|
541
|
+
const Camera = require('expo-camera');
|
|
542
|
+
Camera.getCameraPermissionsAsync.mockResolvedValueOnce({
|
|
543
|
+
status: 'denied',
|
|
544
|
+
canAskAgain: false,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const { result } = renderHook(() => usePermission('camera'));
|
|
548
|
+
await waitFor(() => {
|
|
549
|
+
expect(result.current.status).toBe('blocked');
|
|
550
|
+
expect(result.current.isBlocked).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should allow custom config override', async () => {
|
|
555
|
+
const { result } = renderHook(() =>
|
|
556
|
+
usePermission('camera', { title: 'Custom Title' })
|
|
557
|
+
);
|
|
558
|
+
expect(result.current.config.title).toBe('Custom Title');
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Step 2: Run tests**
|
|
564
|
+
|
|
565
|
+
Run: `npx jest __tests__/hooks/usePermission.test.tsx --no-cache`
|
|
566
|
+
Expected: All 4 tests pass.
|
|
567
|
+
|
|
568
|
+
**Step 3: Commit**
|
|
569
|
+
|
|
570
|
+
```bash
|
|
571
|
+
git add __tests__/hooks/usePermission.test.tsx
|
|
572
|
+
git commit -m "test(permissions): add usePermission hook tests"
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Task 5: Animations — Presets & Transitions
|
|
578
|
+
|
|
579
|
+
**Files:**
|
|
580
|
+
- Create: `utils/animations/presets.ts`
|
|
581
|
+
- Create: `utils/animations/transitions.ts`
|
|
582
|
+
|
|
583
|
+
**Step 1: Create animation presets**
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
// utils/animations/presets.ts
|
|
587
|
+
import {
|
|
588
|
+
withTiming,
|
|
589
|
+
withSpring,
|
|
590
|
+
withDelay,
|
|
591
|
+
Easing,
|
|
592
|
+
SharedValue,
|
|
593
|
+
useAnimatedStyle,
|
|
594
|
+
useSharedValue,
|
|
595
|
+
WithTimingConfig,
|
|
596
|
+
WithSpringConfig,
|
|
597
|
+
} from 'react-native-reanimated';
|
|
598
|
+
|
|
599
|
+
// Timing presets
|
|
600
|
+
export const TIMING = {
|
|
601
|
+
fast: { duration: 200, easing: Easing.out(Easing.cubic) } as WithTimingConfig,
|
|
602
|
+
normal: { duration: 300, easing: Easing.out(Easing.cubic) } as WithTimingConfig,
|
|
603
|
+
slow: { duration: 500, easing: Easing.out(Easing.cubic) } as WithTimingConfig,
|
|
604
|
+
bounce: { duration: 400, easing: Easing.out(Easing.back(1.5)) } as WithTimingConfig,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// Spring presets
|
|
608
|
+
export const SPRING = {
|
|
609
|
+
gentle: { damping: 15, stiffness: 150 } as WithSpringConfig,
|
|
610
|
+
bouncy: { damping: 8, stiffness: 200 } as WithSpringConfig,
|
|
611
|
+
stiff: { damping: 20, stiffness: 300 } as WithSpringConfig,
|
|
612
|
+
snappy: { damping: 12, stiffness: 250 } as WithSpringConfig,
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Entry animation types
|
|
616
|
+
export type EntryAnimation = 'fadeIn' | 'slideUp' | 'slideDown' | 'slideLeft' | 'slideRight' | 'scale' | 'none';
|
|
617
|
+
|
|
618
|
+
export const ENTRY_CONFIGS: Record<
|
|
619
|
+
EntryAnimation,
|
|
620
|
+
{ opacity: number; translateX: number; translateY: number; scale: number }
|
|
621
|
+
> = {
|
|
622
|
+
fadeIn: { opacity: 0, translateX: 0, translateY: 0, scale: 1 },
|
|
623
|
+
slideUp: { opacity: 0, translateX: 0, translateY: 30, scale: 1 },
|
|
624
|
+
slideDown: { opacity: 0, translateX: 0, translateY: -30, scale: 1 },
|
|
625
|
+
slideLeft: { opacity: 0, translateX: 30, translateY: 0, scale: 1 },
|
|
626
|
+
slideRight: { opacity: 0, translateX: -30, translateY: 0, scale: 1 },
|
|
627
|
+
scale: { opacity: 0, translateX: 0, translateY: 0, scale: 0.9 },
|
|
628
|
+
none: { opacity: 1, translateX: 0, translateY: 0, scale: 1 },
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Stagger delay calculator
|
|
632
|
+
export const staggerDelay = (index: number, baseDelay: number = 50): number =>
|
|
633
|
+
index * baseDelay;
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
**Step 2: Create screen transition configs**
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
// utils/animations/transitions.ts
|
|
640
|
+
import { TransitionPresets } from '@react-navigation/stack';
|
|
641
|
+
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Screen transition presets for Expo Router Stack navigation.
|
|
645
|
+
* Use these in your _layout.tsx screenOptions or per-screen options.
|
|
646
|
+
*
|
|
647
|
+
* Example:
|
|
648
|
+
* <Stack.Screen name="detail" options={screenTransitions.modal} />
|
|
649
|
+
*/
|
|
650
|
+
export const screenTransitions = {
|
|
651
|
+
/** Standard iOS-style slide from right */
|
|
652
|
+
slide: {
|
|
653
|
+
animation: 'slide_from_right',
|
|
654
|
+
} satisfies NativeStackNavigationOptions,
|
|
655
|
+
|
|
656
|
+
/** Fade transition */
|
|
657
|
+
fade: {
|
|
658
|
+
animation: 'fade',
|
|
659
|
+
} satisfies NativeStackNavigationOptions,
|
|
660
|
+
|
|
661
|
+
/** Modal presentation (slide from bottom) */
|
|
662
|
+
modal: {
|
|
663
|
+
presentation: 'modal',
|
|
664
|
+
animation: 'slide_from_bottom',
|
|
665
|
+
} satisfies NativeStackNavigationOptions,
|
|
666
|
+
|
|
667
|
+
/** Full-screen modal */
|
|
668
|
+
fullScreenModal: {
|
|
669
|
+
presentation: 'fullScreenModal',
|
|
670
|
+
animation: 'slide_from_bottom',
|
|
671
|
+
} satisfies NativeStackNavigationOptions,
|
|
672
|
+
|
|
673
|
+
/** Transparent modal (for overlays) */
|
|
674
|
+
transparentModal: {
|
|
675
|
+
presentation: 'transparentModal',
|
|
676
|
+
animation: 'fade',
|
|
677
|
+
} satisfies NativeStackNavigationOptions,
|
|
678
|
+
|
|
679
|
+
/** No animation */
|
|
680
|
+
none: {
|
|
681
|
+
animation: 'none',
|
|
682
|
+
} satisfies NativeStackNavigationOptions,
|
|
683
|
+
};
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
**Step 3: Commit**
|
|
687
|
+
|
|
688
|
+
```bash
|
|
689
|
+
git add utils/animations/
|
|
690
|
+
git commit -m "feat(animations): add animation presets, timing configs, and screen transitions"
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## Task 6: Animations — Hooks
|
|
696
|
+
|
|
697
|
+
**Files:**
|
|
698
|
+
- Create: `hooks/useAnimatedEntry.ts`
|
|
699
|
+
- Create: `hooks/useParallax.ts`
|
|
700
|
+
|
|
701
|
+
**Step 1: Create useAnimatedEntry hook**
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
// hooks/useAnimatedEntry.ts
|
|
705
|
+
import { useEffect } from 'react';
|
|
706
|
+
import {
|
|
707
|
+
useSharedValue,
|
|
708
|
+
useAnimatedStyle,
|
|
709
|
+
withTiming,
|
|
710
|
+
withDelay,
|
|
711
|
+
WithTimingConfig,
|
|
712
|
+
} from 'react-native-reanimated';
|
|
713
|
+
import {
|
|
714
|
+
EntryAnimation,
|
|
715
|
+
ENTRY_CONFIGS,
|
|
716
|
+
TIMING,
|
|
717
|
+
} from '@/utils/animations/presets';
|
|
718
|
+
|
|
719
|
+
interface UseAnimatedEntryOptions {
|
|
720
|
+
animation?: EntryAnimation;
|
|
721
|
+
delay?: number;
|
|
722
|
+
timing?: WithTimingConfig;
|
|
723
|
+
autoPlay?: boolean;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function useAnimatedEntry(options: UseAnimatedEntryOptions = {}) {
|
|
727
|
+
const {
|
|
728
|
+
animation = 'fadeIn',
|
|
729
|
+
delay = 0,
|
|
730
|
+
timing = TIMING.normal,
|
|
731
|
+
autoPlay = true,
|
|
732
|
+
} = options;
|
|
733
|
+
|
|
734
|
+
const config = ENTRY_CONFIGS[animation];
|
|
735
|
+
const progress = useSharedValue(0);
|
|
736
|
+
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
if (autoPlay) {
|
|
739
|
+
progress.value = withDelay(delay, withTiming(1, timing));
|
|
740
|
+
}
|
|
741
|
+
}, [autoPlay]);
|
|
742
|
+
|
|
743
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
744
|
+
const t = progress.value;
|
|
745
|
+
return {
|
|
746
|
+
opacity: config.opacity + (1 - config.opacity) * t,
|
|
747
|
+
transform: [
|
|
748
|
+
{ translateX: config.translateX * (1 - t) },
|
|
749
|
+
{ translateY: config.translateY * (1 - t) },
|
|
750
|
+
{ scale: config.scale + (1 - config.scale) * t },
|
|
751
|
+
],
|
|
752
|
+
};
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const play = () => {
|
|
756
|
+
progress.value = 0;
|
|
757
|
+
progress.value = withDelay(delay, withTiming(1, timing));
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
const reset = () => {
|
|
761
|
+
progress.value = 0;
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
return { animatedStyle, play, reset, progress };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Returns animated styles for a list item with stagger delay.
|
|
769
|
+
* Usage: const { animatedStyle } = useStaggeredEntry(index);
|
|
770
|
+
*/
|
|
771
|
+
export function useStaggeredEntry(
|
|
772
|
+
index: number,
|
|
773
|
+
options: Omit<UseAnimatedEntryOptions, 'delay'> & {
|
|
774
|
+
staggerDelay?: number;
|
|
775
|
+
} = {}
|
|
776
|
+
) {
|
|
777
|
+
const { staggerDelay = 50, ...rest } = options;
|
|
778
|
+
return useAnimatedEntry({
|
|
779
|
+
animation: 'slideUp',
|
|
780
|
+
delay: index * staggerDelay,
|
|
781
|
+
...rest,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
**Step 2: Create useParallax hook**
|
|
787
|
+
|
|
788
|
+
```typescript
|
|
789
|
+
// hooks/useParallax.ts
|
|
790
|
+
import {
|
|
791
|
+
useAnimatedScrollHandler,
|
|
792
|
+
useSharedValue,
|
|
793
|
+
useAnimatedStyle,
|
|
794
|
+
interpolate,
|
|
795
|
+
Extrapolation,
|
|
796
|
+
} from 'react-native-reanimated';
|
|
797
|
+
|
|
798
|
+
interface UseParallaxOptions {
|
|
799
|
+
/** How much the parallax element moves relative to scroll (0.5 = half speed) */
|
|
800
|
+
speed?: number;
|
|
801
|
+
/** Height of the parallax header */
|
|
802
|
+
headerHeight?: number;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function useParallax(options: UseParallaxOptions = {}) {
|
|
806
|
+
const { speed = 0.5, headerHeight = 250 } = options;
|
|
807
|
+
const scrollY = useSharedValue(0);
|
|
808
|
+
|
|
809
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
810
|
+
onScroll: (event) => {
|
|
811
|
+
scrollY.value = event.contentOffset.y;
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const parallaxStyle = useAnimatedStyle(() => ({
|
|
816
|
+
transform: [
|
|
817
|
+
{
|
|
818
|
+
translateY: interpolate(
|
|
819
|
+
scrollY.value,
|
|
820
|
+
[-headerHeight, 0, headerHeight],
|
|
821
|
+
[headerHeight * speed, 0, -headerHeight * speed],
|
|
822
|
+
Extrapolation.CLAMP
|
|
823
|
+
),
|
|
824
|
+
},
|
|
825
|
+
],
|
|
826
|
+
}));
|
|
827
|
+
|
|
828
|
+
const headerStyle = useAnimatedStyle(() => ({
|
|
829
|
+
opacity: interpolate(
|
|
830
|
+
scrollY.value,
|
|
831
|
+
[0, headerHeight * 0.8],
|
|
832
|
+
[1, 0],
|
|
833
|
+
Extrapolation.CLAMP
|
|
834
|
+
),
|
|
835
|
+
transform: [
|
|
836
|
+
{
|
|
837
|
+
scale: interpolate(
|
|
838
|
+
scrollY.value,
|
|
839
|
+
[-headerHeight, 0],
|
|
840
|
+
[1.5, 1],
|
|
841
|
+
Extrapolation.CLAMP
|
|
842
|
+
),
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
}));
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
scrollY,
|
|
849
|
+
scrollHandler,
|
|
850
|
+
parallaxStyle,
|
|
851
|
+
headerStyle,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
**Step 3: Commit**
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
git add hooks/useAnimatedEntry.ts hooks/useParallax.ts
|
|
860
|
+
git commit -m "feat(animations): add useAnimatedEntry, useStaggeredEntry, and useParallax hooks"
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
---
|
|
864
|
+
|
|
865
|
+
## Task 7: Animations — Components
|
|
866
|
+
|
|
867
|
+
**Files:**
|
|
868
|
+
- Create: `components/ui/AnimatedScreen.tsx`
|
|
869
|
+
- Create: `components/ui/AnimatedList.tsx`
|
|
870
|
+
|
|
871
|
+
**Step 1: Create AnimatedScreen wrapper**
|
|
872
|
+
|
|
873
|
+
```tsx
|
|
874
|
+
// components/ui/AnimatedScreen.tsx
|
|
875
|
+
import React from 'react';
|
|
876
|
+
import { ViewProps } from 'react-native';
|
|
877
|
+
import Animated from 'react-native-reanimated';
|
|
878
|
+
import { useAnimatedEntry } from '@/hooks/useAnimatedEntry';
|
|
879
|
+
import { EntryAnimation, TIMING } from '@/utils/animations/presets';
|
|
880
|
+
import type { WithTimingConfig } from 'react-native-reanimated';
|
|
881
|
+
|
|
882
|
+
interface AnimatedScreenProps extends ViewProps {
|
|
883
|
+
animation?: EntryAnimation;
|
|
884
|
+
delay?: number;
|
|
885
|
+
timing?: WithTimingConfig;
|
|
886
|
+
children: React.ReactNode;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export function AnimatedScreen({
|
|
890
|
+
animation = 'fadeIn',
|
|
891
|
+
delay = 0,
|
|
892
|
+
timing = TIMING.normal,
|
|
893
|
+
children,
|
|
894
|
+
style,
|
|
895
|
+
...props
|
|
896
|
+
}: AnimatedScreenProps) {
|
|
897
|
+
const { animatedStyle } = useAnimatedEntry({ animation, delay, timing });
|
|
898
|
+
|
|
899
|
+
return (
|
|
900
|
+
<Animated.View style={[{ flex: 1 }, animatedStyle, style]} {...props}>
|
|
901
|
+
{children}
|
|
902
|
+
</Animated.View>
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
**Step 2: Create AnimatedList component**
|
|
908
|
+
|
|
909
|
+
```tsx
|
|
910
|
+
// components/ui/AnimatedList.tsx
|
|
911
|
+
import React, { useCallback } from 'react';
|
|
912
|
+
import { ViewProps } from 'react-native';
|
|
913
|
+
import Animated from 'react-native-reanimated';
|
|
914
|
+
import { useStaggeredEntry } from '@/hooks/useAnimatedEntry';
|
|
915
|
+
import type { EntryAnimation } from '@/utils/animations/presets';
|
|
916
|
+
|
|
917
|
+
interface AnimatedListItemProps extends ViewProps {
|
|
918
|
+
index: number;
|
|
919
|
+
animation?: EntryAnimation;
|
|
920
|
+
staggerDelay?: number;
|
|
921
|
+
children: React.ReactNode;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export function AnimatedListItem({
|
|
925
|
+
index,
|
|
926
|
+
animation = 'slideUp',
|
|
927
|
+
staggerDelay = 50,
|
|
928
|
+
children,
|
|
929
|
+
style,
|
|
930
|
+
...props
|
|
931
|
+
}: AnimatedListItemProps) {
|
|
932
|
+
const { animatedStyle } = useStaggeredEntry(index, {
|
|
933
|
+
animation,
|
|
934
|
+
staggerDelay,
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
return (
|
|
938
|
+
<Animated.View style={[animatedStyle, style]} {...props}>
|
|
939
|
+
{children}
|
|
940
|
+
</Animated.View>
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
**Step 3: Commit**
|
|
946
|
+
|
|
947
|
+
```bash
|
|
948
|
+
git add components/ui/AnimatedScreen.tsx components/ui/AnimatedList.tsx
|
|
949
|
+
git commit -m "feat(animations): add AnimatedScreen and AnimatedListItem components"
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
---
|
|
953
|
+
|
|
954
|
+
## Task 8: Social Login — Types & Adapters
|
|
955
|
+
|
|
956
|
+
**Files:**
|
|
957
|
+
- Create: `services/auth/social/types.ts`
|
|
958
|
+
- Create: `services/auth/social/google.ts`
|
|
959
|
+
- Create: `services/auth/social/apple.ts`
|
|
960
|
+
- Create: `services/auth/social/social-auth.ts`
|
|
961
|
+
|
|
962
|
+
**Step 1: Create social auth types**
|
|
963
|
+
|
|
964
|
+
```typescript
|
|
965
|
+
// services/auth/social/types.ts
|
|
966
|
+
export type SocialProvider = 'google' | 'apple';
|
|
967
|
+
|
|
968
|
+
export interface SocialAuthResult {
|
|
969
|
+
provider: SocialProvider;
|
|
970
|
+
idToken: string;
|
|
971
|
+
accessToken?: string;
|
|
972
|
+
user: {
|
|
973
|
+
id: string;
|
|
974
|
+
email: string | null;
|
|
975
|
+
name: string | null;
|
|
976
|
+
avatar: string | null;
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export interface SocialAuthConfig {
|
|
981
|
+
google?: {
|
|
982
|
+
clientId: string;
|
|
983
|
+
iosClientId?: string;
|
|
984
|
+
androidClientId?: string;
|
|
985
|
+
};
|
|
986
|
+
apple?: {
|
|
987
|
+
/** Apple Sign-In is configured via entitlements, no clientId needed */
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
**Step 2: Create Google Sign-In adapter**
|
|
993
|
+
|
|
994
|
+
```typescript
|
|
995
|
+
// services/auth/social/google.ts
|
|
996
|
+
import * as AuthSession from 'expo-auth-session';
|
|
997
|
+
import * as WebBrowser from 'expo-web-browser';
|
|
998
|
+
import * as Crypto from 'expo-crypto';
|
|
999
|
+
import { SocialAuthResult } from './types';
|
|
1000
|
+
|
|
1001
|
+
WebBrowser.maybeCompleteAuthSession();
|
|
1002
|
+
|
|
1003
|
+
const GOOGLE_DISCOVERY = {
|
|
1004
|
+
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
1005
|
+
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
|
1006
|
+
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
interface GoogleSignInOptions {
|
|
1010
|
+
clientId: string;
|
|
1011
|
+
iosClientId?: string;
|
|
1012
|
+
androidClientId?: string;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function signInWithGoogle(
|
|
1016
|
+
options: GoogleSignInOptions
|
|
1017
|
+
): Promise<SocialAuthResult | null> {
|
|
1018
|
+
const redirectUri = AuthSession.makeRedirectUri();
|
|
1019
|
+
|
|
1020
|
+
const codeVerifier = await Crypto.digestStringAsync(
|
|
1021
|
+
Crypto.CryptoDigestAlgorithm.SHA256,
|
|
1022
|
+
Math.random().toString(36).substring(2) + Date.now().toString(36)
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
const request = new AuthSession.AuthRequest({
|
|
1026
|
+
clientId: options.clientId,
|
|
1027
|
+
scopes: ['openid', 'profile', 'email'],
|
|
1028
|
+
redirectUri,
|
|
1029
|
+
responseType: AuthSession.ResponseType.Code,
|
|
1030
|
+
usePKCE: true,
|
|
1031
|
+
codeChallenge: codeVerifier,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
const result = await request.promptAsync(GOOGLE_DISCOVERY);
|
|
1035
|
+
|
|
1036
|
+
if (result.type !== 'success' || !result.params.code) {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Exchange code for tokens
|
|
1041
|
+
const tokenResult = await AuthSession.exchangeCodeAsync(
|
|
1042
|
+
{
|
|
1043
|
+
clientId: options.clientId,
|
|
1044
|
+
code: result.params.code,
|
|
1045
|
+
redirectUri,
|
|
1046
|
+
extraParams: {
|
|
1047
|
+
code_verifier: request.codeVerifier || '',
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
GOOGLE_DISCOVERY
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
// Decode the ID token to get user info (JWT payload is base64-encoded)
|
|
1054
|
+
const idToken = tokenResult.idToken;
|
|
1055
|
+
if (!idToken) {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const payload = JSON.parse(atob(idToken.split('.')[1]));
|
|
1060
|
+
|
|
1061
|
+
return {
|
|
1062
|
+
provider: 'google',
|
|
1063
|
+
idToken,
|
|
1064
|
+
accessToken: tokenResult.accessToken,
|
|
1065
|
+
user: {
|
|
1066
|
+
id: payload.sub,
|
|
1067
|
+
email: payload.email ?? null,
|
|
1068
|
+
name: payload.name ?? null,
|
|
1069
|
+
avatar: payload.picture ?? null,
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
**Step 3: Create Apple Sign-In adapter**
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
// services/auth/social/apple.ts
|
|
1079
|
+
import * as AppleAuthentication from 'expo-apple-authentication';
|
|
1080
|
+
import { Platform } from 'react-native';
|
|
1081
|
+
import { SocialAuthResult } from './types';
|
|
1082
|
+
|
|
1083
|
+
export function isAppleSignInAvailable(): boolean {
|
|
1084
|
+
return Platform.OS === 'ios';
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
export async function signInWithApple(): Promise<SocialAuthResult | null> {
|
|
1088
|
+
if (!isAppleSignInAvailable()) {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
const credential = await AppleAuthentication.signInAsync({
|
|
1094
|
+
requestedScopes: [
|
|
1095
|
+
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
|
1096
|
+
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
|
1097
|
+
],
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
if (!credential.identityToken) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const fullName = credential.fullName;
|
|
1105
|
+
const name = fullName
|
|
1106
|
+
? [fullName.givenName, fullName.familyName].filter(Boolean).join(' ') || null
|
|
1107
|
+
: null;
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
provider: 'apple',
|
|
1111
|
+
idToken: credential.identityToken,
|
|
1112
|
+
user: {
|
|
1113
|
+
id: credential.user,
|
|
1114
|
+
email: credential.email ?? null,
|
|
1115
|
+
name,
|
|
1116
|
+
avatar: null,
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
} catch (error: any) {
|
|
1120
|
+
if (error.code === 'ERR_REQUEST_CANCELED') {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
throw error;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
**Step 4: Create social auth orchestrator**
|
|
1129
|
+
|
|
1130
|
+
```typescript
|
|
1131
|
+
// services/auth/social/social-auth.ts
|
|
1132
|
+
import { signInWithGoogle } from './google';
|
|
1133
|
+
import { signInWithApple, isAppleSignInAvailable } from './apple';
|
|
1134
|
+
import { SocialAuthConfig, SocialAuthResult, SocialProvider } from './types';
|
|
1135
|
+
|
|
1136
|
+
export { isAppleSignInAvailable } from './apple';
|
|
1137
|
+
export type { SocialAuthResult, SocialProvider } from './types';
|
|
1138
|
+
|
|
1139
|
+
let config: SocialAuthConfig = {};
|
|
1140
|
+
|
|
1141
|
+
export const SocialAuth = {
|
|
1142
|
+
configure(newConfig: SocialAuthConfig) {
|
|
1143
|
+
config = newConfig;
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
async signIn(provider: SocialProvider): Promise<SocialAuthResult | null> {
|
|
1147
|
+
switch (provider) {
|
|
1148
|
+
case 'google': {
|
|
1149
|
+
if (!config.google?.clientId) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
'Google Sign-In requires a clientId. Call SocialAuth.configure() first.'
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return signInWithGoogle(config.google);
|
|
1155
|
+
}
|
|
1156
|
+
case 'apple': {
|
|
1157
|
+
return signInWithApple();
|
|
1158
|
+
}
|
|
1159
|
+
default:
|
|
1160
|
+
throw new Error(`Unknown social provider: ${provider}`);
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
};
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
**Step 5: Commit**
|
|
1167
|
+
|
|
1168
|
+
```bash
|
|
1169
|
+
git add services/auth/social/
|
|
1170
|
+
git commit -m "feat(social-login): add Google and Apple Sign-In adapters with orchestrator"
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
---
|
|
1174
|
+
|
|
1175
|
+
## Task 9: Social Login — UI Component
|
|
1176
|
+
|
|
1177
|
+
**Files:**
|
|
1178
|
+
- Create: `components/auth/SocialLoginButtons.tsx`
|
|
1179
|
+
|
|
1180
|
+
**Step 1: Create SocialLoginButtons component**
|
|
1181
|
+
|
|
1182
|
+
```tsx
|
|
1183
|
+
// components/auth/SocialLoginButtons.tsx
|
|
1184
|
+
import React, { useState } from 'react';
|
|
1185
|
+
import { View, Text, Pressable, Platform, ActivityIndicator } from 'react-native';
|
|
1186
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
1187
|
+
import { SocialAuth, isAppleSignInAvailable, SocialProvider } from '@/services/auth/social/social-auth';
|
|
1188
|
+
import { useTheme } from '@/theme/ThemeContext';
|
|
1189
|
+
|
|
1190
|
+
interface SocialLoginButtonsProps {
|
|
1191
|
+
onSuccess: (result: { provider: SocialProvider; idToken: string; user: any }) => void;
|
|
1192
|
+
onError?: (error: Error) => void;
|
|
1193
|
+
disabled?: boolean;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export function SocialLoginButtons({
|
|
1197
|
+
onSuccess,
|
|
1198
|
+
onError,
|
|
1199
|
+
disabled = false,
|
|
1200
|
+
}: SocialLoginButtonsProps) {
|
|
1201
|
+
const [loading, setLoading] = useState<SocialProvider | null>(null);
|
|
1202
|
+
const { colorScheme } = useTheme();
|
|
1203
|
+
const isDark = colorScheme === 'dark';
|
|
1204
|
+
|
|
1205
|
+
const handleSocialLogin = async (provider: SocialProvider) => {
|
|
1206
|
+
if (loading || disabled) return;
|
|
1207
|
+
setLoading(provider);
|
|
1208
|
+
try {
|
|
1209
|
+
const result = await SocialAuth.signIn(provider);
|
|
1210
|
+
if (result) {
|
|
1211
|
+
onSuccess(result);
|
|
1212
|
+
}
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
onError?.(error as Error);
|
|
1215
|
+
} finally {
|
|
1216
|
+
setLoading(null);
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
const showApple = isAppleSignInAvailable();
|
|
1221
|
+
|
|
1222
|
+
return (
|
|
1223
|
+
<View className="gap-3">
|
|
1224
|
+
{/* Google Sign-In */}
|
|
1225
|
+
<Pressable
|
|
1226
|
+
onPress={() => handleSocialLogin('google')}
|
|
1227
|
+
disabled={loading !== null || disabled}
|
|
1228
|
+
className="flex-row items-center justify-center py-3 px-4 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
|
1229
|
+
accessibilityRole="button"
|
|
1230
|
+
accessibilityLabel="Sign in with Google"
|
|
1231
|
+
>
|
|
1232
|
+
{loading === 'google' ? (
|
|
1233
|
+
<ActivityIndicator size="small" color={isDark ? '#fff' : '#000'} />
|
|
1234
|
+
) : (
|
|
1235
|
+
<>
|
|
1236
|
+
<Ionicons name="logo-google" size={20} color="#4285F4" />
|
|
1237
|
+
<Text className="ml-3 text-base font-medium text-text-light dark:text-text-dark">
|
|
1238
|
+
Continue with Google
|
|
1239
|
+
</Text>
|
|
1240
|
+
</>
|
|
1241
|
+
)}
|
|
1242
|
+
</Pressable>
|
|
1243
|
+
|
|
1244
|
+
{/* Apple Sign-In (iOS only) */}
|
|
1245
|
+
{showApple && (
|
|
1246
|
+
<Pressable
|
|
1247
|
+
onPress={() => handleSocialLogin('apple')}
|
|
1248
|
+
disabled={loading !== null || disabled}
|
|
1249
|
+
className="flex-row items-center justify-center py-3 px-4 rounded-xl bg-black dark:bg-white"
|
|
1250
|
+
accessibilityRole="button"
|
|
1251
|
+
accessibilityLabel="Sign in with Apple"
|
|
1252
|
+
>
|
|
1253
|
+
{loading === 'apple' ? (
|
|
1254
|
+
<ActivityIndicator
|
|
1255
|
+
size="small"
|
|
1256
|
+
color={isDark ? '#000' : '#fff'}
|
|
1257
|
+
/>
|
|
1258
|
+
) : (
|
|
1259
|
+
<>
|
|
1260
|
+
<Ionicons
|
|
1261
|
+
name="logo-apple"
|
|
1262
|
+
size={20}
|
|
1263
|
+
color={isDark ? '#000' : '#fff'}
|
|
1264
|
+
/>
|
|
1265
|
+
<Text
|
|
1266
|
+
className={`ml-3 text-base font-medium ${
|
|
1267
|
+
isDark ? 'text-black' : 'text-white'
|
|
1268
|
+
}`}
|
|
1269
|
+
>
|
|
1270
|
+
Continue with Apple
|
|
1271
|
+
</Text>
|
|
1272
|
+
</>
|
|
1273
|
+
)}
|
|
1274
|
+
</Pressable>
|
|
1275
|
+
)}
|
|
1276
|
+
</View>
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
**Step 2: Commit**
|
|
1282
|
+
|
|
1283
|
+
```bash
|
|
1284
|
+
git add components/auth/SocialLoginButtons.tsx
|
|
1285
|
+
git commit -m "feat(social-login): add SocialLoginButtons component"
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
---
|
|
1289
|
+
|
|
1290
|
+
## Task 10: Analytics — Adapter & Console Implementation
|
|
1291
|
+
|
|
1292
|
+
**Files:**
|
|
1293
|
+
- Create: `services/analytics/types.ts`
|
|
1294
|
+
- Create: `services/analytics/analytics-adapter.ts`
|
|
1295
|
+
- Create: `services/analytics/adapters/console.ts`
|
|
1296
|
+
|
|
1297
|
+
**Step 1: Create analytics types**
|
|
1298
|
+
|
|
1299
|
+
```typescript
|
|
1300
|
+
// services/analytics/types.ts
|
|
1301
|
+
export interface AnalyticsAdapter {
|
|
1302
|
+
initialize(): Promise<void>;
|
|
1303
|
+
track(event: string, properties?: Record<string, unknown>): void;
|
|
1304
|
+
screen(name: string, properties?: Record<string, unknown>): void;
|
|
1305
|
+
identify(userId: string, traits?: Record<string, unknown>): void;
|
|
1306
|
+
reset(): void;
|
|
1307
|
+
setUserProperties(properties: Record<string, unknown>): void;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
export interface AnalyticsConfig {
|
|
1311
|
+
enabled: boolean;
|
|
1312
|
+
debug: boolean;
|
|
1313
|
+
}
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
**Step 2: Create analytics adapter manager**
|
|
1317
|
+
|
|
1318
|
+
```typescript
|
|
1319
|
+
// services/analytics/analytics-adapter.ts
|
|
1320
|
+
import { AnalyticsAdapter, AnalyticsConfig } from './types';
|
|
1321
|
+
import { ConsoleAnalyticsAdapter } from './adapters/console';
|
|
1322
|
+
|
|
1323
|
+
let activeAdapter: AnalyticsAdapter = new ConsoleAnalyticsAdapter();
|
|
1324
|
+
let config: AnalyticsConfig = { enabled: true, debug: __DEV__ };
|
|
1325
|
+
|
|
1326
|
+
export const Analytics = {
|
|
1327
|
+
configure(newConfig: Partial<AnalyticsConfig>) {
|
|
1328
|
+
config = { ...config, ...newConfig };
|
|
1329
|
+
},
|
|
1330
|
+
|
|
1331
|
+
setAdapter(adapter: AnalyticsAdapter) {
|
|
1332
|
+
activeAdapter = adapter;
|
|
1333
|
+
},
|
|
1334
|
+
|
|
1335
|
+
async initialize(): Promise<void> {
|
|
1336
|
+
if (!config.enabled) return;
|
|
1337
|
+
await activeAdapter.initialize();
|
|
1338
|
+
},
|
|
1339
|
+
|
|
1340
|
+
track(event: string, properties?: Record<string, unknown>) {
|
|
1341
|
+
if (!config.enabled) return;
|
|
1342
|
+
activeAdapter.track(event, properties);
|
|
1343
|
+
},
|
|
1344
|
+
|
|
1345
|
+
screen(name: string, properties?: Record<string, unknown>) {
|
|
1346
|
+
if (!config.enabled) return;
|
|
1347
|
+
activeAdapter.screen(name, properties);
|
|
1348
|
+
},
|
|
1349
|
+
|
|
1350
|
+
identify(userId: string, traits?: Record<string, unknown>) {
|
|
1351
|
+
if (!config.enabled) return;
|
|
1352
|
+
activeAdapter.identify(userId, traits);
|
|
1353
|
+
},
|
|
1354
|
+
|
|
1355
|
+
reset() {
|
|
1356
|
+
if (!config.enabled) return;
|
|
1357
|
+
activeAdapter.reset();
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
setUserProperties(properties: Record<string, unknown>) {
|
|
1361
|
+
if (!config.enabled) return;
|
|
1362
|
+
activeAdapter.setUserProperties(properties);
|
|
1363
|
+
},
|
|
1364
|
+
};
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
**Step 3: Create console adapter**
|
|
1368
|
+
|
|
1369
|
+
```typescript
|
|
1370
|
+
// services/analytics/adapters/console.ts
|
|
1371
|
+
import { AnalyticsAdapter } from '../types';
|
|
1372
|
+
|
|
1373
|
+
export class ConsoleAnalyticsAdapter implements AnalyticsAdapter {
|
|
1374
|
+
private prefix = '[Analytics]';
|
|
1375
|
+
|
|
1376
|
+
async initialize(): Promise<void> {
|
|
1377
|
+
if (__DEV__) {
|
|
1378
|
+
console.log(`${this.prefix} Initialized (console adapter)`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
track(event: string, properties?: Record<string, unknown>): void {
|
|
1383
|
+
if (__DEV__) {
|
|
1384
|
+
console.log(`${this.prefix} Track:`, event, properties ?? '');
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
screen(name: string, properties?: Record<string, unknown>): void {
|
|
1389
|
+
if (__DEV__) {
|
|
1390
|
+
console.log(`${this.prefix} Screen:`, name, properties ?? '');
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
identify(userId: string, traits?: Record<string, unknown>): void {
|
|
1395
|
+
if (__DEV__) {
|
|
1396
|
+
console.log(`${this.prefix} Identify:`, userId, traits ?? '');
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
reset(): void {
|
|
1401
|
+
if (__DEV__) {
|
|
1402
|
+
console.log(`${this.prefix} Reset`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
setUserProperties(properties: Record<string, unknown>): void {
|
|
1407
|
+
if (__DEV__) {
|
|
1408
|
+
console.log(`${this.prefix} User Properties:`, properties);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
**Step 4: Commit**
|
|
1415
|
+
|
|
1416
|
+
```bash
|
|
1417
|
+
git add services/analytics/
|
|
1418
|
+
git commit -m "feat(analytics): add analytics adapter pattern with console implementation"
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
---
|
|
1422
|
+
|
|
1423
|
+
## Task 11: Analytics — Hooks & Provider
|
|
1424
|
+
|
|
1425
|
+
**Files:**
|
|
1426
|
+
- Create: `hooks/useTrackScreen.ts`
|
|
1427
|
+
- Create: `hooks/useTrackEvent.ts`
|
|
1428
|
+
- Create: `components/providers/AnalyticsProvider.tsx`
|
|
1429
|
+
|
|
1430
|
+
**Step 1: Create useTrackScreen hook**
|
|
1431
|
+
|
|
1432
|
+
```typescript
|
|
1433
|
+
// hooks/useTrackScreen.ts
|
|
1434
|
+
import { useEffect } from 'react';
|
|
1435
|
+
import { usePathname, useSegments } from 'expo-router';
|
|
1436
|
+
import { Analytics } from '@/services/analytics/analytics-adapter';
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Automatically tracks screen views based on Expo Router navigation.
|
|
1440
|
+
* Place this in your root layout to track all screen changes.
|
|
1441
|
+
*/
|
|
1442
|
+
export function useTrackScreen() {
|
|
1443
|
+
const pathname = usePathname();
|
|
1444
|
+
const segments = useSegments();
|
|
1445
|
+
|
|
1446
|
+
useEffect(() => {
|
|
1447
|
+
if (pathname) {
|
|
1448
|
+
Analytics.screen(pathname, {
|
|
1449
|
+
segments: segments.join('/'),
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}, [pathname]);
|
|
1453
|
+
}
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
**Step 2: Create useTrackEvent hook**
|
|
1457
|
+
|
|
1458
|
+
```typescript
|
|
1459
|
+
// hooks/useTrackEvent.ts
|
|
1460
|
+
import { useCallback } from 'react';
|
|
1461
|
+
import { Analytics } from '@/services/analytics/analytics-adapter';
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Returns a typed track function for analytics events.
|
|
1465
|
+
*
|
|
1466
|
+
* Usage:
|
|
1467
|
+
* const track = useTrackEvent();
|
|
1468
|
+
* track('button_pressed', { button: 'submit' });
|
|
1469
|
+
*/
|
|
1470
|
+
export function useTrackEvent() {
|
|
1471
|
+
return useCallback(
|
|
1472
|
+
(event: string, properties?: Record<string, unknown>) => {
|
|
1473
|
+
Analytics.track(event, properties);
|
|
1474
|
+
},
|
|
1475
|
+
[]
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
**Step 3: Create AnalyticsProvider**
|
|
1481
|
+
|
|
1482
|
+
```tsx
|
|
1483
|
+
// components/providers/AnalyticsProvider.tsx
|
|
1484
|
+
import React, { useEffect } from 'react';
|
|
1485
|
+
import { Analytics } from '@/services/analytics/analytics-adapter';
|
|
1486
|
+
import { useTrackScreen } from '@/hooks/useTrackScreen';
|
|
1487
|
+
import { AppConfig } from '@/constants/config';
|
|
1488
|
+
|
|
1489
|
+
interface AnalyticsProviderProps {
|
|
1490
|
+
children: React.ReactNode;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
export function AnalyticsProvider({ children }: AnalyticsProviderProps) {
|
|
1494
|
+
useEffect(() => {
|
|
1495
|
+
Analytics.configure({
|
|
1496
|
+
enabled: AppConfig.featureFlags.ENABLE_ANALYTICS,
|
|
1497
|
+
debug: __DEV__,
|
|
1498
|
+
});
|
|
1499
|
+
Analytics.initialize();
|
|
1500
|
+
}, []);
|
|
1501
|
+
|
|
1502
|
+
// Track screen views
|
|
1503
|
+
useTrackScreen();
|
|
1504
|
+
|
|
1505
|
+
return <>{children}</>;
|
|
1506
|
+
}
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
**Step 4: Commit**
|
|
1510
|
+
|
|
1511
|
+
```bash
|
|
1512
|
+
git add hooks/useTrackScreen.ts hooks/useTrackEvent.ts components/providers/AnalyticsProvider.tsx
|
|
1513
|
+
git commit -m "feat(analytics): add tracking hooks and AnalyticsProvider"
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
---
|
|
1517
|
+
|
|
1518
|
+
## Task 12: Payments — Adapter & Types
|
|
1519
|
+
|
|
1520
|
+
**Files:**
|
|
1521
|
+
- Create: `services/payments/types.ts`
|
|
1522
|
+
- Create: `services/payments/payment-adapter.ts`
|
|
1523
|
+
- Create: `services/payments/adapters/mock.ts`
|
|
1524
|
+
|
|
1525
|
+
**Step 1: Create payment types**
|
|
1526
|
+
|
|
1527
|
+
```typescript
|
|
1528
|
+
// services/payments/types.ts
|
|
1529
|
+
export interface Product {
|
|
1530
|
+
id: string;
|
|
1531
|
+
title: string;
|
|
1532
|
+
description: string;
|
|
1533
|
+
price: number;
|
|
1534
|
+
priceString: string;
|
|
1535
|
+
currency: string;
|
|
1536
|
+
type: 'consumable' | 'non_consumable' | 'subscription';
|
|
1537
|
+
/** For subscriptions */
|
|
1538
|
+
subscriptionPeriod?: string;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
export interface Purchase {
|
|
1542
|
+
id: string;
|
|
1543
|
+
productId: string;
|
|
1544
|
+
transactionDate: string;
|
|
1545
|
+
transactionReceipt?: string;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
export type SubscriptionStatus =
|
|
1549
|
+
| 'active'
|
|
1550
|
+
| 'expired'
|
|
1551
|
+
| 'cancelled'
|
|
1552
|
+
| 'grace_period'
|
|
1553
|
+
| 'none';
|
|
1554
|
+
|
|
1555
|
+
export interface SubscriptionInfo {
|
|
1556
|
+
status: SubscriptionStatus;
|
|
1557
|
+
productId: string | null;
|
|
1558
|
+
expiresAt: string | null;
|
|
1559
|
+
willRenew: boolean;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
export interface PaymentAdapter {
|
|
1563
|
+
initialize(): Promise<void>;
|
|
1564
|
+
getProducts(ids: string[]): Promise<Product[]>;
|
|
1565
|
+
purchase(productId: string): Promise<Purchase>;
|
|
1566
|
+
restorePurchases(): Promise<Purchase[]>;
|
|
1567
|
+
getSubscriptionStatus(): Promise<SubscriptionInfo>;
|
|
1568
|
+
}
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
**Step 2: Create payment adapter manager**
|
|
1572
|
+
|
|
1573
|
+
```typescript
|
|
1574
|
+
// services/payments/payment-adapter.ts
|
|
1575
|
+
import { PaymentAdapter, Product, Purchase, SubscriptionInfo } from './types';
|
|
1576
|
+
import { MockPaymentAdapter } from './adapters/mock';
|
|
1577
|
+
|
|
1578
|
+
let activeAdapter: PaymentAdapter = new MockPaymentAdapter();
|
|
1579
|
+
|
|
1580
|
+
export const Payments = {
|
|
1581
|
+
setAdapter(adapter: PaymentAdapter) {
|
|
1582
|
+
activeAdapter = adapter;
|
|
1583
|
+
},
|
|
1584
|
+
|
|
1585
|
+
async initialize(): Promise<void> {
|
|
1586
|
+
return activeAdapter.initialize();
|
|
1587
|
+
},
|
|
1588
|
+
|
|
1589
|
+
async getProducts(ids: string[]): Promise<Product[]> {
|
|
1590
|
+
return activeAdapter.getProducts(ids);
|
|
1591
|
+
},
|
|
1592
|
+
|
|
1593
|
+
async purchase(productId: string): Promise<Purchase> {
|
|
1594
|
+
return activeAdapter.purchase(productId);
|
|
1595
|
+
},
|
|
1596
|
+
|
|
1597
|
+
async restorePurchases(): Promise<Purchase[]> {
|
|
1598
|
+
return activeAdapter.restorePurchases();
|
|
1599
|
+
},
|
|
1600
|
+
|
|
1601
|
+
async getSubscriptionStatus(): Promise<SubscriptionInfo> {
|
|
1602
|
+
return activeAdapter.getSubscriptionStatus();
|
|
1603
|
+
},
|
|
1604
|
+
};
|
|
1605
|
+
```
|
|
1606
|
+
|
|
1607
|
+
**Step 3: Create mock adapter**
|
|
1608
|
+
|
|
1609
|
+
```typescript
|
|
1610
|
+
// services/payments/adapters/mock.ts
|
|
1611
|
+
import { PaymentAdapter, Product, Purchase, SubscriptionInfo } from '../types';
|
|
1612
|
+
|
|
1613
|
+
const MOCK_PRODUCTS: Product[] = [
|
|
1614
|
+
{
|
|
1615
|
+
id: 'premium_monthly',
|
|
1616
|
+
title: 'Premium Monthly',
|
|
1617
|
+
description: 'Full access to all features',
|
|
1618
|
+
price: 9.99,
|
|
1619
|
+
priceString: '$9.99',
|
|
1620
|
+
currency: 'USD',
|
|
1621
|
+
type: 'subscription',
|
|
1622
|
+
subscriptionPeriod: 'P1M',
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
id: 'premium_yearly',
|
|
1626
|
+
title: 'Premium Yearly',
|
|
1627
|
+
description: 'Full access — save 40%',
|
|
1628
|
+
price: 69.99,
|
|
1629
|
+
priceString: '$69.99',
|
|
1630
|
+
currency: 'USD',
|
|
1631
|
+
type: 'subscription',
|
|
1632
|
+
subscriptionPeriod: 'P1Y',
|
|
1633
|
+
},
|
|
1634
|
+
];
|
|
1635
|
+
|
|
1636
|
+
export class MockPaymentAdapter implements PaymentAdapter {
|
|
1637
|
+
private purchases: Purchase[] = [];
|
|
1638
|
+
|
|
1639
|
+
async initialize(): Promise<void> {
|
|
1640
|
+
if (__DEV__) {
|
|
1641
|
+
console.log('[Payments] Mock adapter initialized');
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
async getProducts(ids: string[]): Promise<Product[]> {
|
|
1646
|
+
// Simulate network delay
|
|
1647
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1648
|
+
return MOCK_PRODUCTS.filter((p) => ids.includes(p.id));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async purchase(productId: string): Promise<Purchase> {
|
|
1652
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1653
|
+
const purchase: Purchase = {
|
|
1654
|
+
id: `mock_${Date.now()}`,
|
|
1655
|
+
productId,
|
|
1656
|
+
transactionDate: new Date().toISOString(),
|
|
1657
|
+
transactionReceipt: 'mock_receipt',
|
|
1658
|
+
};
|
|
1659
|
+
this.purchases.push(purchase);
|
|
1660
|
+
return purchase;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async restorePurchases(): Promise<Purchase[]> {
|
|
1664
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1665
|
+
return this.purchases;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async getSubscriptionStatus(): Promise<SubscriptionInfo> {
|
|
1669
|
+
const activePurchase = this.purchases.find(
|
|
1670
|
+
(p) => p.productId.includes('premium')
|
|
1671
|
+
);
|
|
1672
|
+
if (activePurchase) {
|
|
1673
|
+
return {
|
|
1674
|
+
status: 'active',
|
|
1675
|
+
productId: activePurchase.productId,
|
|
1676
|
+
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1677
|
+
willRenew: true,
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
return {
|
|
1681
|
+
status: 'none',
|
|
1682
|
+
productId: null,
|
|
1683
|
+
expiresAt: null,
|
|
1684
|
+
willRenew: false,
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
```
|
|
1689
|
+
|
|
1690
|
+
**Step 4: Commit**
|
|
1691
|
+
|
|
1692
|
+
```bash
|
|
1693
|
+
git add services/payments/
|
|
1694
|
+
git commit -m "feat(payments): add payment adapter pattern with mock implementation"
|
|
1695
|
+
```
|
|
1696
|
+
|
|
1697
|
+
---
|
|
1698
|
+
|
|
1699
|
+
## Task 13: Payments — Hooks & Components
|
|
1700
|
+
|
|
1701
|
+
**Files:**
|
|
1702
|
+
- Create: `hooks/useProducts.ts`
|
|
1703
|
+
- Create: `hooks/usePurchase.ts`
|
|
1704
|
+
- Create: `hooks/useSubscription.ts`
|
|
1705
|
+
- Create: `components/ui/Paywall.tsx`
|
|
1706
|
+
- Create: `components/ui/PurchaseButton.tsx`
|
|
1707
|
+
|
|
1708
|
+
**Step 1: Create payment hooks**
|
|
1709
|
+
|
|
1710
|
+
```typescript
|
|
1711
|
+
// hooks/useProducts.ts
|
|
1712
|
+
import { useQuery } from '@tanstack/react-query';
|
|
1713
|
+
import { Payments } from '@/services/payments/payment-adapter';
|
|
1714
|
+
|
|
1715
|
+
export function useProducts(productIds: string[]) {
|
|
1716
|
+
return useQuery({
|
|
1717
|
+
queryKey: ['products', productIds],
|
|
1718
|
+
queryFn: () => Payments.getProducts(productIds),
|
|
1719
|
+
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
```
|
|
1723
|
+
|
|
1724
|
+
```typescript
|
|
1725
|
+
// hooks/usePurchase.ts
|
|
1726
|
+
import { useState, useCallback } from 'react';
|
|
1727
|
+
import { Payments } from '@/services/payments/payment-adapter';
|
|
1728
|
+
import { Purchase } from '@/services/payments/types';
|
|
1729
|
+
|
|
1730
|
+
interface UsePurchaseReturn {
|
|
1731
|
+
purchase: (productId: string) => Promise<Purchase | null>;
|
|
1732
|
+
restore: () => Promise<Purchase[]>;
|
|
1733
|
+
isLoading: boolean;
|
|
1734
|
+
error: Error | null;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
export function usePurchase(): UsePurchaseReturn {
|
|
1738
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
1739
|
+
const [error, setError] = useState<Error | null>(null);
|
|
1740
|
+
|
|
1741
|
+
const purchase = useCallback(async (productId: string) => {
|
|
1742
|
+
setIsLoading(true);
|
|
1743
|
+
setError(null);
|
|
1744
|
+
try {
|
|
1745
|
+
const result = await Payments.purchase(productId);
|
|
1746
|
+
return result;
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
setError(err as Error);
|
|
1749
|
+
return null;
|
|
1750
|
+
} finally {
|
|
1751
|
+
setIsLoading(false);
|
|
1752
|
+
}
|
|
1753
|
+
}, []);
|
|
1754
|
+
|
|
1755
|
+
const restore = useCallback(async () => {
|
|
1756
|
+
setIsLoading(true);
|
|
1757
|
+
setError(null);
|
|
1758
|
+
try {
|
|
1759
|
+
const results = await Payments.restorePurchases();
|
|
1760
|
+
return results;
|
|
1761
|
+
} catch (err) {
|
|
1762
|
+
setError(err as Error);
|
|
1763
|
+
return [];
|
|
1764
|
+
} finally {
|
|
1765
|
+
setIsLoading(false);
|
|
1766
|
+
}
|
|
1767
|
+
}, []);
|
|
1768
|
+
|
|
1769
|
+
return { purchase, restore, isLoading, error };
|
|
1770
|
+
}
|
|
1771
|
+
```
|
|
1772
|
+
|
|
1773
|
+
```typescript
|
|
1774
|
+
// hooks/useSubscription.ts
|
|
1775
|
+
import { useQuery } from '@tanstack/react-query';
|
|
1776
|
+
import { Payments } from '@/services/payments/payment-adapter';
|
|
1777
|
+
|
|
1778
|
+
export function useSubscription() {
|
|
1779
|
+
const query = useQuery({
|
|
1780
|
+
queryKey: ['subscription-status'],
|
|
1781
|
+
queryFn: () => Payments.getSubscriptionStatus(),
|
|
1782
|
+
staleTime: 60 * 1000, // 1 minute
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
return {
|
|
1786
|
+
...query,
|
|
1787
|
+
isActive: query.data?.status === 'active' || query.data?.status === 'grace_period',
|
|
1788
|
+
isPro: query.data?.status === 'active',
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
```
|
|
1792
|
+
|
|
1793
|
+
**Step 2: Create Paywall component**
|
|
1794
|
+
|
|
1795
|
+
```tsx
|
|
1796
|
+
// components/ui/Paywall.tsx
|
|
1797
|
+
import React from 'react';
|
|
1798
|
+
import { View, Text, ScrollView } from 'react-native';
|
|
1799
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
1800
|
+
import { useProducts } from '@/hooks/useProducts';
|
|
1801
|
+
import { PurchaseButton } from './PurchaseButton';
|
|
1802
|
+
import { Product } from '@/services/payments/types';
|
|
1803
|
+
import { Skeleton } from './Skeleton';
|
|
1804
|
+
|
|
1805
|
+
interface PaywallProps {
|
|
1806
|
+
productIds: string[];
|
|
1807
|
+
features?: string[];
|
|
1808
|
+
title?: string;
|
|
1809
|
+
subtitle?: string;
|
|
1810
|
+
onPurchaseSuccess?: (productId: string) => void;
|
|
1811
|
+
onRestore?: () => void;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
export function Paywall({
|
|
1815
|
+
productIds,
|
|
1816
|
+
features = [],
|
|
1817
|
+
title = 'Upgrade to Premium',
|
|
1818
|
+
subtitle = 'Unlock all features',
|
|
1819
|
+
onPurchaseSuccess,
|
|
1820
|
+
onRestore,
|
|
1821
|
+
}: PaywallProps) {
|
|
1822
|
+
const { data: products, isLoading } = useProducts(productIds);
|
|
1823
|
+
|
|
1824
|
+
if (isLoading) {
|
|
1825
|
+
return (
|
|
1826
|
+
<View className="flex-1 px-6 pt-8">
|
|
1827
|
+
<Skeleton width="60%" height={32} />
|
|
1828
|
+
<Skeleton width="80%" height={20} className="mt-2" />
|
|
1829
|
+
<Skeleton height={120} className="mt-6" />
|
|
1830
|
+
<Skeleton height={120} className="mt-3" />
|
|
1831
|
+
</View>
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
return (
|
|
1836
|
+
<ScrollView className="flex-1 px-6 pt-8" showsVerticalScrollIndicator={false}>
|
|
1837
|
+
<Text className="text-2xl font-bold text-text-light dark:text-text-dark">
|
|
1838
|
+
{title}
|
|
1839
|
+
</Text>
|
|
1840
|
+
<Text className="mt-1 text-base text-muted-light dark:text-muted-dark">
|
|
1841
|
+
{subtitle}
|
|
1842
|
+
</Text>
|
|
1843
|
+
|
|
1844
|
+
{/* Features list */}
|
|
1845
|
+
{features.length > 0 && (
|
|
1846
|
+
<View className="mt-6 gap-3">
|
|
1847
|
+
{features.map((feature, i) => (
|
|
1848
|
+
<View key={i} className="flex-row items-center gap-3">
|
|
1849
|
+
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
|
1850
|
+
<Text className="text-base text-text-light dark:text-text-dark">
|
|
1851
|
+
{feature}
|
|
1852
|
+
</Text>
|
|
1853
|
+
</View>
|
|
1854
|
+
))}
|
|
1855
|
+
</View>
|
|
1856
|
+
)}
|
|
1857
|
+
|
|
1858
|
+
{/* Products */}
|
|
1859
|
+
<View className="mt-6 gap-3">
|
|
1860
|
+
{products?.map((product) => (
|
|
1861
|
+
<ProductCard
|
|
1862
|
+
key={product.id}
|
|
1863
|
+
product={product}
|
|
1864
|
+
onPurchaseSuccess={onPurchaseSuccess}
|
|
1865
|
+
/>
|
|
1866
|
+
))}
|
|
1867
|
+
</View>
|
|
1868
|
+
|
|
1869
|
+
{/* Restore */}
|
|
1870
|
+
{onRestore && (
|
|
1871
|
+
<Text
|
|
1872
|
+
onPress={onRestore}
|
|
1873
|
+
className="mt-4 text-center text-sm text-primary-500 underline"
|
|
1874
|
+
>
|
|
1875
|
+
Restore purchases
|
|
1876
|
+
</Text>
|
|
1877
|
+
)}
|
|
1878
|
+
</ScrollView>
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function ProductCard({
|
|
1883
|
+
product,
|
|
1884
|
+
onPurchaseSuccess,
|
|
1885
|
+
}: {
|
|
1886
|
+
product: Product;
|
|
1887
|
+
onPurchaseSuccess?: (productId: string) => void;
|
|
1888
|
+
}) {
|
|
1889
|
+
return (
|
|
1890
|
+
<View className="p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-surface-light dark:bg-surface-dark">
|
|
1891
|
+
<Text className="text-lg font-semibold text-text-light dark:text-text-dark">
|
|
1892
|
+
{product.title}
|
|
1893
|
+
</Text>
|
|
1894
|
+
<Text className="text-sm text-muted-light dark:text-muted-dark mt-1">
|
|
1895
|
+
{product.description}
|
|
1896
|
+
</Text>
|
|
1897
|
+
<View className="flex-row items-center justify-between mt-3">
|
|
1898
|
+
<Text className="text-xl font-bold text-primary-600 dark:text-primary-400">
|
|
1899
|
+
{product.priceString}
|
|
1900
|
+
{product.subscriptionPeriod && (
|
|
1901
|
+
<Text className="text-sm font-normal text-muted-light dark:text-muted-dark">
|
|
1902
|
+
/{product.subscriptionPeriod === 'P1M' ? 'mo' : 'yr'}
|
|
1903
|
+
</Text>
|
|
1904
|
+
)}
|
|
1905
|
+
</Text>
|
|
1906
|
+
<PurchaseButton
|
|
1907
|
+
productId={product.id}
|
|
1908
|
+
onSuccess={() => onPurchaseSuccess?.(product.id)}
|
|
1909
|
+
/>
|
|
1910
|
+
</View>
|
|
1911
|
+
</View>
|
|
1912
|
+
);
|
|
1913
|
+
}
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
**Step 3: Create PurchaseButton component**
|
|
1917
|
+
|
|
1918
|
+
```tsx
|
|
1919
|
+
// components/ui/PurchaseButton.tsx
|
|
1920
|
+
import React from 'react';
|
|
1921
|
+
import { usePurchase } from '@/hooks/usePurchase';
|
|
1922
|
+
import { Button } from './Button';
|
|
1923
|
+
|
|
1924
|
+
interface PurchaseButtonProps {
|
|
1925
|
+
productId: string;
|
|
1926
|
+
label?: string;
|
|
1927
|
+
onSuccess?: () => void;
|
|
1928
|
+
onError?: (error: Error) => void;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
export function PurchaseButton({
|
|
1932
|
+
productId,
|
|
1933
|
+
label = 'Subscribe',
|
|
1934
|
+
onSuccess,
|
|
1935
|
+
onError,
|
|
1936
|
+
}: PurchaseButtonProps) {
|
|
1937
|
+
const { purchase, isLoading } = usePurchase();
|
|
1938
|
+
|
|
1939
|
+
const handlePress = async () => {
|
|
1940
|
+
const result = await purchase(productId);
|
|
1941
|
+
if (result) {
|
|
1942
|
+
onSuccess?.();
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
return (
|
|
1947
|
+
<Button
|
|
1948
|
+
onPress={handlePress}
|
|
1949
|
+
loading={isLoading}
|
|
1950
|
+
variant="primary"
|
|
1951
|
+
size="sm"
|
|
1952
|
+
>
|
|
1953
|
+
{label}
|
|
1954
|
+
</Button>
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
```
|
|
1958
|
+
|
|
1959
|
+
**Step 4: Commit**
|
|
1960
|
+
|
|
1961
|
+
```bash
|
|
1962
|
+
git add hooks/useProducts.ts hooks/usePurchase.ts hooks/useSubscription.ts components/ui/Paywall.tsx components/ui/PurchaseButton.tsx
|
|
1963
|
+
git commit -m "feat(payments): add payment hooks, Paywall and PurchaseButton components"
|
|
1964
|
+
```
|
|
1965
|
+
|
|
1966
|
+
---
|
|
1967
|
+
|
|
1968
|
+
## Task 14: File Upload / Media — Services
|
|
1969
|
+
|
|
1970
|
+
**Files:**
|
|
1971
|
+
- Create: `services/media/media-picker.ts`
|
|
1972
|
+
- Create: `services/media/compression.ts`
|
|
1973
|
+
- Create: `services/media/media-upload.ts`
|
|
1974
|
+
|
|
1975
|
+
**Step 1: Create media picker service**
|
|
1976
|
+
|
|
1977
|
+
```typescript
|
|
1978
|
+
// services/media/media-picker.ts
|
|
1979
|
+
import * as ImagePicker from 'expo-image-picker';
|
|
1980
|
+
|
|
1981
|
+
export interface PickedMedia {
|
|
1982
|
+
uri: string;
|
|
1983
|
+
type: 'image' | 'video';
|
|
1984
|
+
width: number;
|
|
1985
|
+
height: number;
|
|
1986
|
+
fileSize?: number;
|
|
1987
|
+
fileName?: string;
|
|
1988
|
+
duration?: number;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
interface PickOptions {
|
|
1992
|
+
mediaTypes?: 'images' | 'videos' | 'all';
|
|
1993
|
+
allowsEditing?: boolean;
|
|
1994
|
+
quality?: number;
|
|
1995
|
+
aspect?: [number, number];
|
|
1996
|
+
allowsMultipleSelection?: boolean;
|
|
1997
|
+
selectionLimit?: number;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
const mediaTypeMap = {
|
|
2001
|
+
images: ImagePicker.MediaTypeOptions.Images,
|
|
2002
|
+
videos: ImagePicker.MediaTypeOptions.Videos,
|
|
2003
|
+
all: ImagePicker.MediaTypeOptions.All,
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
function mapAsset(asset: ImagePicker.ImagePickerAsset): PickedMedia {
|
|
2007
|
+
return {
|
|
2008
|
+
uri: asset.uri,
|
|
2009
|
+
type: asset.type === 'video' ? 'video' : 'image',
|
|
2010
|
+
width: asset.width,
|
|
2011
|
+
height: asset.height,
|
|
2012
|
+
fileSize: asset.fileSize ?? undefined,
|
|
2013
|
+
fileName: asset.fileName ?? undefined,
|
|
2014
|
+
duration: asset.duration ?? undefined,
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
export async function pickFromLibrary(
|
|
2019
|
+
options: PickOptions = {}
|
|
2020
|
+
): Promise<PickedMedia[]> {
|
|
2021
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
2022
|
+
mediaTypes: mediaTypeMap[options.mediaTypes ?? 'images'],
|
|
2023
|
+
allowsEditing: options.allowsEditing ?? false,
|
|
2024
|
+
quality: options.quality ?? 0.8,
|
|
2025
|
+
aspect: options.aspect,
|
|
2026
|
+
allowsMultipleSelection: options.allowsMultipleSelection ?? false,
|
|
2027
|
+
selectionLimit: options.selectionLimit,
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
if (result.canceled) return [];
|
|
2031
|
+
return result.assets.map(mapAsset);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
export async function pickFromCamera(
|
|
2035
|
+
options: Omit<PickOptions, 'allowsMultipleSelection' | 'selectionLimit'> = {}
|
|
2036
|
+
): Promise<PickedMedia | null> {
|
|
2037
|
+
const result = await ImagePicker.launchCameraAsync({
|
|
2038
|
+
mediaTypes: mediaTypeMap[options.mediaTypes ?? 'images'],
|
|
2039
|
+
allowsEditing: options.allowsEditing ?? false,
|
|
2040
|
+
quality: options.quality ?? 0.8,
|
|
2041
|
+
aspect: options.aspect,
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
if (result.canceled || !result.assets[0]) return null;
|
|
2045
|
+
return mapAsset(result.assets[0]);
|
|
2046
|
+
}
|
|
2047
|
+
```
|
|
2048
|
+
|
|
2049
|
+
**Step 2: Create compression service**
|
|
2050
|
+
|
|
2051
|
+
```typescript
|
|
2052
|
+
// services/media/compression.ts
|
|
2053
|
+
import * as ImageManipulator from 'expo-image-manipulator';
|
|
2054
|
+
|
|
2055
|
+
interface CompressionOptions {
|
|
2056
|
+
maxWidth?: number;
|
|
2057
|
+
maxHeight?: number;
|
|
2058
|
+
quality?: number;
|
|
2059
|
+
format?: 'jpeg' | 'png' | 'webp';
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
export async function compressImage(
|
|
2063
|
+
uri: string,
|
|
2064
|
+
options: CompressionOptions = {}
|
|
2065
|
+
): Promise<{ uri: string; width: number; height: number }> {
|
|
2066
|
+
const { maxWidth = 1080, maxHeight = 1080, quality = 0.7, format = 'jpeg' } = options;
|
|
2067
|
+
|
|
2068
|
+
const formatMap = {
|
|
2069
|
+
jpeg: ImageManipulator.SaveFormat.JPEG,
|
|
2070
|
+
png: ImageManipulator.SaveFormat.PNG,
|
|
2071
|
+
webp: ImageManipulator.SaveFormat.WEBP,
|
|
2072
|
+
};
|
|
2073
|
+
|
|
2074
|
+
const actions: ImageManipulator.Action[] = [];
|
|
2075
|
+
|
|
2076
|
+
// Only resize if we have max dimensions
|
|
2077
|
+
if (maxWidth || maxHeight) {
|
|
2078
|
+
actions.push({
|
|
2079
|
+
resize: {
|
|
2080
|
+
width: maxWidth,
|
|
2081
|
+
height: maxHeight,
|
|
2082
|
+
},
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
const result = await ImageManipulator.manipulateAsync(uri, actions, {
|
|
2087
|
+
compress: quality,
|
|
2088
|
+
format: formatMap[format],
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
return {
|
|
2092
|
+
uri: result.uri,
|
|
2093
|
+
width: result.width,
|
|
2094
|
+
height: result.height,
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
```
|
|
2098
|
+
|
|
2099
|
+
**Step 3: Create upload service**
|
|
2100
|
+
|
|
2101
|
+
```typescript
|
|
2102
|
+
// services/media/media-upload.ts
|
|
2103
|
+
export interface UploadProgress {
|
|
2104
|
+
loaded: number;
|
|
2105
|
+
total: number;
|
|
2106
|
+
percentage: number;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
export interface UploadOptions {
|
|
2110
|
+
url: string;
|
|
2111
|
+
uri: string;
|
|
2112
|
+
fieldName?: string;
|
|
2113
|
+
mimeType?: string;
|
|
2114
|
+
headers?: Record<string, string>;
|
|
2115
|
+
extraFields?: Record<string, string>;
|
|
2116
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
export interface UploadResult {
|
|
2120
|
+
status: number;
|
|
2121
|
+
body: string;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
export function uploadFile(options: UploadOptions): {
|
|
2125
|
+
promise: Promise<UploadResult>;
|
|
2126
|
+
abort: () => void;
|
|
2127
|
+
} {
|
|
2128
|
+
const {
|
|
2129
|
+
url,
|
|
2130
|
+
uri,
|
|
2131
|
+
fieldName = 'file',
|
|
2132
|
+
mimeType = 'image/jpeg',
|
|
2133
|
+
headers = {},
|
|
2134
|
+
extraFields = {},
|
|
2135
|
+
onProgress,
|
|
2136
|
+
} = options;
|
|
2137
|
+
|
|
2138
|
+
const xhr = new XMLHttpRequest();
|
|
2139
|
+
const formData = new FormData();
|
|
2140
|
+
|
|
2141
|
+
// Extract filename from URI
|
|
2142
|
+
const fileName = uri.split('/').pop() || 'upload';
|
|
2143
|
+
|
|
2144
|
+
formData.append(fieldName, {
|
|
2145
|
+
uri,
|
|
2146
|
+
type: mimeType,
|
|
2147
|
+
name: fileName,
|
|
2148
|
+
} as any);
|
|
2149
|
+
|
|
2150
|
+
// Append extra fields
|
|
2151
|
+
Object.entries(extraFields).forEach(([key, value]) => {
|
|
2152
|
+
formData.append(key, value);
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
const promise = new Promise<UploadResult>((resolve, reject) => {
|
|
2156
|
+
xhr.upload.addEventListener('progress', (event) => {
|
|
2157
|
+
if (event.lengthComputable && onProgress) {
|
|
2158
|
+
onProgress({
|
|
2159
|
+
loaded: event.loaded,
|
|
2160
|
+
total: event.total,
|
|
2161
|
+
percentage: Math.round((event.loaded / event.total) * 100),
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
xhr.addEventListener('load', () => {
|
|
2167
|
+
resolve({ status: xhr.status, body: xhr.responseText });
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
xhr.addEventListener('error', () => {
|
|
2171
|
+
reject(new Error('Upload failed'));
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
xhr.addEventListener('abort', () => {
|
|
2175
|
+
reject(new Error('Upload cancelled'));
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
xhr.open('POST', url);
|
|
2179
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
2180
|
+
xhr.setRequestHeader(key, value);
|
|
2181
|
+
});
|
|
2182
|
+
xhr.send(formData);
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
return {
|
|
2186
|
+
promise,
|
|
2187
|
+
abort: () => xhr.abort(),
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
**Step 4: Commit**
|
|
2193
|
+
|
|
2194
|
+
```bash
|
|
2195
|
+
git add services/media/
|
|
2196
|
+
git commit -m "feat(media): add media picker, image compression, and upload services"
|
|
2197
|
+
```
|
|
2198
|
+
|
|
2199
|
+
---
|
|
2200
|
+
|
|
2201
|
+
## Task 15: File Upload / Media — Hooks & Components
|
|
2202
|
+
|
|
2203
|
+
**Files:**
|
|
2204
|
+
- Create: `hooks/useImagePicker.ts`
|
|
2205
|
+
- Create: `hooks/useUpload.ts`
|
|
2206
|
+
- Create: `components/ui/ImagePickerButton.tsx`
|
|
2207
|
+
- Create: `components/ui/UploadProgress.tsx`
|
|
2208
|
+
|
|
2209
|
+
**Step 1: Create useImagePicker hook**
|
|
2210
|
+
|
|
2211
|
+
```typescript
|
|
2212
|
+
// hooks/useImagePicker.ts
|
|
2213
|
+
import { useState, useCallback } from 'react';
|
|
2214
|
+
import {
|
|
2215
|
+
PickedMedia,
|
|
2216
|
+
pickFromLibrary,
|
|
2217
|
+
pickFromCamera,
|
|
2218
|
+
} from '@/services/media/media-picker';
|
|
2219
|
+
import { compressImage } from '@/services/media/compression';
|
|
2220
|
+
import { usePermission } from '@/hooks/usePermission';
|
|
2221
|
+
|
|
2222
|
+
interface UseImagePickerOptions {
|
|
2223
|
+
compress?: boolean;
|
|
2224
|
+
maxWidth?: number;
|
|
2225
|
+
maxHeight?: number;
|
|
2226
|
+
quality?: number;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
interface UseImagePickerReturn {
|
|
2230
|
+
pickFromLibrary: () => Promise<PickedMedia | null>;
|
|
2231
|
+
pickFromCamera: () => Promise<PickedMedia | null>;
|
|
2232
|
+
selectedMedia: PickedMedia | null;
|
|
2233
|
+
isLoading: boolean;
|
|
2234
|
+
clear: () => void;
|
|
2235
|
+
cameraPermission: ReturnType<typeof usePermission>;
|
|
2236
|
+
mediaLibraryPermission: ReturnType<typeof usePermission>;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
export function useImagePicker(
|
|
2240
|
+
options: UseImagePickerOptions = {}
|
|
2241
|
+
): UseImagePickerReturn {
|
|
2242
|
+
const { compress = true, maxWidth = 1080, maxHeight = 1080, quality = 0.7 } = options;
|
|
2243
|
+
const [selectedMedia, setSelectedMedia] = useState<PickedMedia | null>(null);
|
|
2244
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
2245
|
+
|
|
2246
|
+
const cameraPermission = usePermission('camera');
|
|
2247
|
+
const mediaLibraryPermission = usePermission('mediaLibrary');
|
|
2248
|
+
|
|
2249
|
+
const processMedia = useCallback(
|
|
2250
|
+
async (media: PickedMedia): Promise<PickedMedia> => {
|
|
2251
|
+
if (!compress || media.type !== 'image') return media;
|
|
2252
|
+
const compressed = await compressImage(media.uri, {
|
|
2253
|
+
maxWidth,
|
|
2254
|
+
maxHeight,
|
|
2255
|
+
quality,
|
|
2256
|
+
});
|
|
2257
|
+
return { ...media, ...compressed };
|
|
2258
|
+
},
|
|
2259
|
+
[compress, maxWidth, maxHeight, quality]
|
|
2260
|
+
);
|
|
2261
|
+
|
|
2262
|
+
const handlePickFromLibrary = useCallback(async () => {
|
|
2263
|
+
if (!mediaLibraryPermission.isGranted) {
|
|
2264
|
+
const status = await mediaLibraryPermission.request();
|
|
2265
|
+
if (status !== 'granted') return null;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
setIsLoading(true);
|
|
2269
|
+
try {
|
|
2270
|
+
const results = await pickFromLibrary();
|
|
2271
|
+
if (results.length === 0) return null;
|
|
2272
|
+
const processed = await processMedia(results[0]);
|
|
2273
|
+
setSelectedMedia(processed);
|
|
2274
|
+
return processed;
|
|
2275
|
+
} finally {
|
|
2276
|
+
setIsLoading(false);
|
|
2277
|
+
}
|
|
2278
|
+
}, [mediaLibraryPermission, processMedia]);
|
|
2279
|
+
|
|
2280
|
+
const handlePickFromCamera = useCallback(async () => {
|
|
2281
|
+
if (!cameraPermission.isGranted) {
|
|
2282
|
+
const status = await cameraPermission.request();
|
|
2283
|
+
if (status !== 'granted') return null;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
setIsLoading(true);
|
|
2287
|
+
try {
|
|
2288
|
+
const result = await pickFromCamera();
|
|
2289
|
+
if (!result) return null;
|
|
2290
|
+
const processed = await processMedia(result);
|
|
2291
|
+
setSelectedMedia(processed);
|
|
2292
|
+
return processed;
|
|
2293
|
+
} finally {
|
|
2294
|
+
setIsLoading(false);
|
|
2295
|
+
}
|
|
2296
|
+
}, [cameraPermission, processMedia]);
|
|
2297
|
+
|
|
2298
|
+
const clear = useCallback(() => setSelectedMedia(null), []);
|
|
2299
|
+
|
|
2300
|
+
return {
|
|
2301
|
+
pickFromLibrary: handlePickFromLibrary,
|
|
2302
|
+
pickFromCamera: handlePickFromCamera,
|
|
2303
|
+
selectedMedia,
|
|
2304
|
+
isLoading,
|
|
2305
|
+
clear,
|
|
2306
|
+
cameraPermission,
|
|
2307
|
+
mediaLibraryPermission,
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
```
|
|
2311
|
+
|
|
2312
|
+
**Step 2: Create useUpload hook**
|
|
2313
|
+
|
|
2314
|
+
```typescript
|
|
2315
|
+
// hooks/useUpload.ts
|
|
2316
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2317
|
+
import {
|
|
2318
|
+
uploadFile,
|
|
2319
|
+
UploadProgress,
|
|
2320
|
+
UploadResult,
|
|
2321
|
+
} from '@/services/media/media-upload';
|
|
2322
|
+
|
|
2323
|
+
interface UseUploadOptions {
|
|
2324
|
+
url: string;
|
|
2325
|
+
headers?: Record<string, string>;
|
|
2326
|
+
fieldName?: string;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
interface UseUploadReturn {
|
|
2330
|
+
upload: (uri: string, extraFields?: Record<string, string>) => Promise<UploadResult | null>;
|
|
2331
|
+
progress: UploadProgress | null;
|
|
2332
|
+
isUploading: boolean;
|
|
2333
|
+
error: Error | null;
|
|
2334
|
+
cancel: () => void;
|
|
2335
|
+
reset: () => void;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
export function useUpload(options: UseUploadOptions): UseUploadReturn {
|
|
2339
|
+
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
|
2340
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
2341
|
+
const [error, setError] = useState<Error | null>(null);
|
|
2342
|
+
const abortRef = useRef<(() => void) | null>(null);
|
|
2343
|
+
|
|
2344
|
+
const upload = useCallback(
|
|
2345
|
+
async (uri: string, extraFields?: Record<string, string>) => {
|
|
2346
|
+
setIsUploading(true);
|
|
2347
|
+
setError(null);
|
|
2348
|
+
setProgress(null);
|
|
2349
|
+
|
|
2350
|
+
try {
|
|
2351
|
+
const { promise, abort } = uploadFile({
|
|
2352
|
+
...options,
|
|
2353
|
+
uri,
|
|
2354
|
+
extraFields,
|
|
2355
|
+
onProgress: setProgress,
|
|
2356
|
+
});
|
|
2357
|
+
abortRef.current = abort;
|
|
2358
|
+
const result = await promise;
|
|
2359
|
+
return result;
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
setError(err as Error);
|
|
2362
|
+
return null;
|
|
2363
|
+
} finally {
|
|
2364
|
+
setIsUploading(false);
|
|
2365
|
+
abortRef.current = null;
|
|
2366
|
+
}
|
|
2367
|
+
},
|
|
2368
|
+
[options]
|
|
2369
|
+
);
|
|
2370
|
+
|
|
2371
|
+
const cancel = useCallback(() => {
|
|
2372
|
+
abortRef.current?.();
|
|
2373
|
+
}, []);
|
|
2374
|
+
|
|
2375
|
+
const reset = useCallback(() => {
|
|
2376
|
+
setProgress(null);
|
|
2377
|
+
setError(null);
|
|
2378
|
+
setIsUploading(false);
|
|
2379
|
+
}, []);
|
|
2380
|
+
|
|
2381
|
+
return { upload, progress, isUploading, error, cancel, reset };
|
|
2382
|
+
}
|
|
2383
|
+
```
|
|
2384
|
+
|
|
2385
|
+
**Step 3: Create UI components**
|
|
2386
|
+
|
|
2387
|
+
```tsx
|
|
2388
|
+
// components/ui/ImagePickerButton.tsx
|
|
2389
|
+
import React from 'react';
|
|
2390
|
+
import { View, Text, Pressable, Image } from 'react-native';
|
|
2391
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2392
|
+
import { useImagePicker } from '@/hooks/useImagePicker';
|
|
2393
|
+
|
|
2394
|
+
interface ImagePickerButtonProps {
|
|
2395
|
+
onImageSelected?: (uri: string) => void;
|
|
2396
|
+
size?: number;
|
|
2397
|
+
placeholder?: string;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
export function ImagePickerButton({
|
|
2401
|
+
onImageSelected,
|
|
2402
|
+
size = 100,
|
|
2403
|
+
placeholder = 'Add Photo',
|
|
2404
|
+
}: ImagePickerButtonProps) {
|
|
2405
|
+
const { pickFromLibrary, selectedMedia, isLoading, clear } = useImagePicker();
|
|
2406
|
+
|
|
2407
|
+
const handlePress = async () => {
|
|
2408
|
+
const media = await pickFromLibrary();
|
|
2409
|
+
if (media) {
|
|
2410
|
+
onImageSelected?.(media.uri);
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
if (selectedMedia) {
|
|
2415
|
+
return (
|
|
2416
|
+
<Pressable onPress={clear} accessibilityRole="button" accessibilityLabel="Remove selected photo">
|
|
2417
|
+
<Image
|
|
2418
|
+
source={{ uri: selectedMedia.uri }}
|
|
2419
|
+
style={{ width: size, height: size, borderRadius: 12 }}
|
|
2420
|
+
/>
|
|
2421
|
+
<View className="absolute -top-2 -right-2 bg-red-500 rounded-full w-6 h-6 items-center justify-center">
|
|
2422
|
+
<Ionicons name="close" size={14} color="white" />
|
|
2423
|
+
</View>
|
|
2424
|
+
</Pressable>
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
return (
|
|
2429
|
+
<Pressable
|
|
2430
|
+
onPress={handlePress}
|
|
2431
|
+
disabled={isLoading}
|
|
2432
|
+
className="items-center justify-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl"
|
|
2433
|
+
style={{ width: size, height: size }}
|
|
2434
|
+
accessibilityRole="button"
|
|
2435
|
+
accessibilityLabel={placeholder}
|
|
2436
|
+
>
|
|
2437
|
+
<Ionicons name="camera-outline" size={24} color="#9ca3af" />
|
|
2438
|
+
<Text className="text-xs text-muted-light dark:text-muted-dark mt-1">
|
|
2439
|
+
{placeholder}
|
|
2440
|
+
</Text>
|
|
2441
|
+
</Pressable>
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
```
|
|
2445
|
+
|
|
2446
|
+
```tsx
|
|
2447
|
+
// components/ui/UploadProgress.tsx
|
|
2448
|
+
import React from 'react';
|
|
2449
|
+
import { View, Text, Pressable } from 'react-native';
|
|
2450
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2451
|
+
import { UploadProgress as UploadProgressType } from '@/services/media/media-upload';
|
|
2452
|
+
|
|
2453
|
+
interface UploadProgressProps {
|
|
2454
|
+
progress: UploadProgressType | null;
|
|
2455
|
+
isUploading: boolean;
|
|
2456
|
+
error: Error | null;
|
|
2457
|
+
onCancel?: () => void;
|
|
2458
|
+
onRetry?: () => void;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
export function UploadProgress({
|
|
2462
|
+
progress,
|
|
2463
|
+
isUploading,
|
|
2464
|
+
error,
|
|
2465
|
+
onCancel,
|
|
2466
|
+
onRetry,
|
|
2467
|
+
}: UploadProgressProps) {
|
|
2468
|
+
if (!isUploading && !error && !progress) return null;
|
|
2469
|
+
|
|
2470
|
+
return (
|
|
2471
|
+
<View className="px-4 py-3 rounded-xl bg-surface-light dark:bg-surface-dark">
|
|
2472
|
+
{error ? (
|
|
2473
|
+
<View className="flex-row items-center justify-between">
|
|
2474
|
+
<View className="flex-row items-center gap-2">
|
|
2475
|
+
<Ionicons name="alert-circle" size={20} color="#ef4444" />
|
|
2476
|
+
<Text className="text-sm text-red-500">Upload failed</Text>
|
|
2477
|
+
</View>
|
|
2478
|
+
{onRetry && (
|
|
2479
|
+
<Pressable onPress={onRetry}>
|
|
2480
|
+
<Text className="text-sm text-primary-500 font-medium">Retry</Text>
|
|
2481
|
+
</Pressable>
|
|
2482
|
+
)}
|
|
2483
|
+
</View>
|
|
2484
|
+
) : (
|
|
2485
|
+
<>
|
|
2486
|
+
<View className="flex-row items-center justify-between mb-2">
|
|
2487
|
+
<Text className="text-sm text-text-light dark:text-text-dark">
|
|
2488
|
+
{isUploading ? 'Uploading...' : 'Complete'}
|
|
2489
|
+
</Text>
|
|
2490
|
+
<View className="flex-row items-center gap-2">
|
|
2491
|
+
<Text className="text-sm text-muted-light dark:text-muted-dark">
|
|
2492
|
+
{progress?.percentage ?? 0}%
|
|
2493
|
+
</Text>
|
|
2494
|
+
{isUploading && onCancel && (
|
|
2495
|
+
<Pressable onPress={onCancel}>
|
|
2496
|
+
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
|
2497
|
+
</Pressable>
|
|
2498
|
+
)}
|
|
2499
|
+
</View>
|
|
2500
|
+
</View>
|
|
2501
|
+
<View className="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
2502
|
+
<View
|
|
2503
|
+
className="h-full bg-primary-500 rounded-full"
|
|
2504
|
+
style={{ width: `${progress?.percentage ?? 0}%` }}
|
|
2505
|
+
/>
|
|
2506
|
+
</View>
|
|
2507
|
+
</>
|
|
2508
|
+
)}
|
|
2509
|
+
</View>
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
```
|
|
2513
|
+
|
|
2514
|
+
**Step 4: Commit**
|
|
2515
|
+
|
|
2516
|
+
```bash
|
|
2517
|
+
git add hooks/useImagePicker.ts hooks/useUpload.ts components/ui/ImagePickerButton.tsx components/ui/UploadProgress.tsx
|
|
2518
|
+
git commit -m "feat(media): add image picker, upload hooks and UI components"
|
|
2519
|
+
```
|
|
2520
|
+
|
|
2521
|
+
---
|
|
2522
|
+
|
|
2523
|
+
## Task 16: WebSockets — Manager & Types
|
|
2524
|
+
|
|
2525
|
+
**Files:**
|
|
2526
|
+
- Create: `services/realtime/types.ts`
|
|
2527
|
+
- Create: `services/realtime/websocket-manager.ts`
|
|
2528
|
+
|
|
2529
|
+
**Step 1: Create WebSocket types**
|
|
2530
|
+
|
|
2531
|
+
```typescript
|
|
2532
|
+
// services/realtime/types.ts
|
|
2533
|
+
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
|
2534
|
+
|
|
2535
|
+
export interface WebSocketMessage<T = unknown> {
|
|
2536
|
+
type: string;
|
|
2537
|
+
channel?: string;
|
|
2538
|
+
payload: T;
|
|
2539
|
+
timestamp: number;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
export interface WebSocketConfig {
|
|
2543
|
+
url: string;
|
|
2544
|
+
/** Auth token to send on connection */
|
|
2545
|
+
getToken?: () => Promise<string | null>;
|
|
2546
|
+
/** Auto-reconnect on disconnect (default: true) */
|
|
2547
|
+
autoReconnect?: boolean;
|
|
2548
|
+
/** Max reconnect attempts (default: 10) */
|
|
2549
|
+
maxReconnectAttempts?: number;
|
|
2550
|
+
/** Base delay for reconnect backoff in ms (default: 1000) */
|
|
2551
|
+
reconnectBaseDelay?: number;
|
|
2552
|
+
/** Heartbeat interval in ms (default: 30000) */
|
|
2553
|
+
heartbeatInterval?: number;
|
|
2554
|
+
/** Connection timeout in ms (default: 10000) */
|
|
2555
|
+
connectionTimeout?: number;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
export type MessageHandler<T = unknown> = (message: WebSocketMessage<T>) => void;
|
|
2559
|
+
export type StatusHandler = (status: ConnectionStatus) => void;
|
|
2560
|
+
```
|
|
2561
|
+
|
|
2562
|
+
**Step 2: Create WebSocket manager**
|
|
2563
|
+
|
|
2564
|
+
```typescript
|
|
2565
|
+
// services/realtime/websocket-manager.ts
|
|
2566
|
+
import {
|
|
2567
|
+
ConnectionStatus,
|
|
2568
|
+
WebSocketConfig,
|
|
2569
|
+
WebSocketMessage,
|
|
2570
|
+
MessageHandler,
|
|
2571
|
+
StatusHandler,
|
|
2572
|
+
} from './types';
|
|
2573
|
+
|
|
2574
|
+
export class WebSocketManager {
|
|
2575
|
+
private ws: WebSocket | null = null;
|
|
2576
|
+
private config: WebSocketConfig;
|
|
2577
|
+
private status: ConnectionStatus = 'disconnected';
|
|
2578
|
+
private reconnectAttempts = 0;
|
|
2579
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2580
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
2581
|
+
private messageQueue: WebSocketMessage[] = [];
|
|
2582
|
+
|
|
2583
|
+
// Listeners
|
|
2584
|
+
private messageHandlers = new Map<string, Set<MessageHandler>>();
|
|
2585
|
+
private globalHandlers = new Set<MessageHandler>();
|
|
2586
|
+
private statusHandlers = new Set<StatusHandler>();
|
|
2587
|
+
|
|
2588
|
+
constructor(config: WebSocketConfig) {
|
|
2589
|
+
this.config = {
|
|
2590
|
+
autoReconnect: true,
|
|
2591
|
+
maxReconnectAttempts: 10,
|
|
2592
|
+
reconnectBaseDelay: 1000,
|
|
2593
|
+
heartbeatInterval: 30000,
|
|
2594
|
+
connectionTimeout: 10000,
|
|
2595
|
+
...config,
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
async connect(): Promise<void> {
|
|
2600
|
+
if (this.ws?.readyState === WebSocket.OPEN) return;
|
|
2601
|
+
|
|
2602
|
+
this.setStatus('connecting');
|
|
2603
|
+
|
|
2604
|
+
let url = this.config.url;
|
|
2605
|
+
if (this.config.getToken) {
|
|
2606
|
+
const token = await this.config.getToken();
|
|
2607
|
+
if (token) {
|
|
2608
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
2609
|
+
url = `${url}${separator}token=${token}`;
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
return new Promise((resolve, reject) => {
|
|
2614
|
+
const timeout = setTimeout(() => {
|
|
2615
|
+
this.ws?.close();
|
|
2616
|
+
reject(new Error('Connection timeout'));
|
|
2617
|
+
}, this.config.connectionTimeout);
|
|
2618
|
+
|
|
2619
|
+
this.ws = new WebSocket(url);
|
|
2620
|
+
|
|
2621
|
+
this.ws.onopen = () => {
|
|
2622
|
+
clearTimeout(timeout);
|
|
2623
|
+
this.setStatus('connected');
|
|
2624
|
+
this.reconnectAttempts = 0;
|
|
2625
|
+
this.startHeartbeat();
|
|
2626
|
+
this.flushQueue();
|
|
2627
|
+
resolve();
|
|
2628
|
+
};
|
|
2629
|
+
|
|
2630
|
+
this.ws.onmessage = (event) => {
|
|
2631
|
+
try {
|
|
2632
|
+
const message: WebSocketMessage = JSON.parse(event.data);
|
|
2633
|
+
this.handleMessage(message);
|
|
2634
|
+
} catch {
|
|
2635
|
+
// Non-JSON message, ignore
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2639
|
+
this.ws.onclose = () => {
|
|
2640
|
+
clearTimeout(timeout);
|
|
2641
|
+
this.stopHeartbeat();
|
|
2642
|
+
this.setStatus('disconnected');
|
|
2643
|
+
this.attemptReconnect();
|
|
2644
|
+
};
|
|
2645
|
+
|
|
2646
|
+
this.ws.onerror = () => {
|
|
2647
|
+
clearTimeout(timeout);
|
|
2648
|
+
reject(new Error('WebSocket error'));
|
|
2649
|
+
};
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
disconnect(): void {
|
|
2654
|
+
this.config.autoReconnect = false;
|
|
2655
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
2656
|
+
this.stopHeartbeat();
|
|
2657
|
+
this.ws?.close();
|
|
2658
|
+
this.ws = null;
|
|
2659
|
+
this.setStatus('disconnected');
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
send<T>(type: string, payload: T, channel?: string): void {
|
|
2663
|
+
const message: WebSocketMessage<T> = {
|
|
2664
|
+
type,
|
|
2665
|
+
channel,
|
|
2666
|
+
payload,
|
|
2667
|
+
timestamp: Date.now(),
|
|
2668
|
+
};
|
|
2669
|
+
|
|
2670
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
2671
|
+
this.ws.send(JSON.stringify(message));
|
|
2672
|
+
} else {
|
|
2673
|
+
this.messageQueue.push(message as WebSocketMessage);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
subscribe(channel: string, handler: MessageHandler): () => void {
|
|
2678
|
+
if (!this.messageHandlers.has(channel)) {
|
|
2679
|
+
this.messageHandlers.set(channel, new Set());
|
|
2680
|
+
}
|
|
2681
|
+
this.messageHandlers.get(channel)!.add(handler);
|
|
2682
|
+
|
|
2683
|
+
// Send subscribe message to server
|
|
2684
|
+
this.send('subscribe', { channel });
|
|
2685
|
+
|
|
2686
|
+
return () => {
|
|
2687
|
+
this.messageHandlers.get(channel)?.delete(handler);
|
|
2688
|
+
if (this.messageHandlers.get(channel)?.size === 0) {
|
|
2689
|
+
this.messageHandlers.delete(channel);
|
|
2690
|
+
this.send('unsubscribe', { channel });
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
onMessage(handler: MessageHandler): () => void {
|
|
2696
|
+
this.globalHandlers.add(handler);
|
|
2697
|
+
return () => this.globalHandlers.delete(handler);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
onStatusChange(handler: StatusHandler): () => void {
|
|
2701
|
+
this.statusHandlers.add(handler);
|
|
2702
|
+
return () => this.statusHandlers.delete(handler);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
getStatus(): ConnectionStatus {
|
|
2706
|
+
return this.status;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// --- Private ---
|
|
2710
|
+
|
|
2711
|
+
private setStatus(status: ConnectionStatus): void {
|
|
2712
|
+
this.status = status;
|
|
2713
|
+
this.statusHandlers.forEach((handler) => handler(status));
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
private handleMessage(message: WebSocketMessage): void {
|
|
2717
|
+
// Global handlers
|
|
2718
|
+
this.globalHandlers.forEach((handler) => handler(message));
|
|
2719
|
+
|
|
2720
|
+
// Channel handlers
|
|
2721
|
+
if (message.channel) {
|
|
2722
|
+
this.messageHandlers.get(message.channel)?.forEach((handler) => handler(message));
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
private attemptReconnect(): void {
|
|
2727
|
+
if (
|
|
2728
|
+
!this.config.autoReconnect ||
|
|
2729
|
+
this.reconnectAttempts >= (this.config.maxReconnectAttempts ?? 10)
|
|
2730
|
+
) {
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
this.setStatus('reconnecting');
|
|
2735
|
+
const delay =
|
|
2736
|
+
(this.config.reconnectBaseDelay ?? 1000) * Math.pow(2, this.reconnectAttempts);
|
|
2737
|
+
this.reconnectAttempts++;
|
|
2738
|
+
|
|
2739
|
+
this.reconnectTimer = setTimeout(() => {
|
|
2740
|
+
this.connect().catch(() => {
|
|
2741
|
+
// Reconnect failed, will try again via onclose
|
|
2742
|
+
});
|
|
2743
|
+
}, Math.min(delay, 30000));
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
private startHeartbeat(): void {
|
|
2747
|
+
this.heartbeatTimer = setInterval(() => {
|
|
2748
|
+
this.send('ping', {});
|
|
2749
|
+
}, this.config.heartbeatInterval);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
private stopHeartbeat(): void {
|
|
2753
|
+
if (this.heartbeatTimer) {
|
|
2754
|
+
clearInterval(this.heartbeatTimer);
|
|
2755
|
+
this.heartbeatTimer = null;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
private flushQueue(): void {
|
|
2760
|
+
while (this.messageQueue.length > 0) {
|
|
2761
|
+
const message = this.messageQueue.shift()!;
|
|
2762
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
2763
|
+
this.ws.send(JSON.stringify(message));
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
```
|
|
2769
|
+
|
|
2770
|
+
**Step 3: Commit**
|
|
2771
|
+
|
|
2772
|
+
```bash
|
|
2773
|
+
git add services/realtime/
|
|
2774
|
+
git commit -m "feat(realtime): add WebSocket manager with auto-reconnect and channel support"
|
|
2775
|
+
```
|
|
2776
|
+
|
|
2777
|
+
---
|
|
2778
|
+
|
|
2779
|
+
## Task 17: WebSockets — Hooks
|
|
2780
|
+
|
|
2781
|
+
**Files:**
|
|
2782
|
+
- Create: `hooks/useWebSocket.ts`
|
|
2783
|
+
- Create: `hooks/useChannel.ts`
|
|
2784
|
+
- Create: `hooks/usePresence.ts`
|
|
2785
|
+
|
|
2786
|
+
**Step 1: Create useWebSocket hook**
|
|
2787
|
+
|
|
2788
|
+
```typescript
|
|
2789
|
+
// hooks/useWebSocket.ts
|
|
2790
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
2791
|
+
import { WebSocketManager } from '@/services/realtime/websocket-manager';
|
|
2792
|
+
import { ConnectionStatus, WebSocketConfig } from '@/services/realtime/types';
|
|
2793
|
+
|
|
2794
|
+
interface UseWebSocketReturn {
|
|
2795
|
+
status: ConnectionStatus;
|
|
2796
|
+
send: <T>(type: string, payload: T, channel?: string) => void;
|
|
2797
|
+
connect: () => Promise<void>;
|
|
2798
|
+
disconnect: () => void;
|
|
2799
|
+
manager: WebSocketManager;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
export function useWebSocket(config: WebSocketConfig): UseWebSocketReturn {
|
|
2803
|
+
const managerRef = useRef<WebSocketManager | null>(null);
|
|
2804
|
+
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
|
2805
|
+
|
|
2806
|
+
if (!managerRef.current) {
|
|
2807
|
+
managerRef.current = new WebSocketManager(config);
|
|
2808
|
+
}
|
|
2809
|
+
const manager = managerRef.current;
|
|
2810
|
+
|
|
2811
|
+
useEffect(() => {
|
|
2812
|
+
const unsubscribe = manager.onStatusChange(setStatus);
|
|
2813
|
+
manager.connect().catch(() => {});
|
|
2814
|
+
|
|
2815
|
+
return () => {
|
|
2816
|
+
unsubscribe();
|
|
2817
|
+
manager.disconnect();
|
|
2818
|
+
};
|
|
2819
|
+
}, []);
|
|
2820
|
+
|
|
2821
|
+
const send = useCallback(
|
|
2822
|
+
<T,>(type: string, payload: T, channel?: string) => {
|
|
2823
|
+
manager.send(type, payload, channel);
|
|
2824
|
+
},
|
|
2825
|
+
[manager]
|
|
2826
|
+
);
|
|
2827
|
+
|
|
2828
|
+
const connect = useCallback(() => manager.connect(), [manager]);
|
|
2829
|
+
const disconnect = useCallback(() => manager.disconnect(), [manager]);
|
|
2830
|
+
|
|
2831
|
+
return { status, send, connect, disconnect, manager };
|
|
2832
|
+
}
|
|
2833
|
+
```
|
|
2834
|
+
|
|
2835
|
+
**Step 2: Create useChannel hook**
|
|
2836
|
+
|
|
2837
|
+
```typescript
|
|
2838
|
+
// hooks/useChannel.ts
|
|
2839
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
2840
|
+
import { WebSocketManager } from '@/services/realtime/websocket-manager';
|
|
2841
|
+
import { WebSocketMessage } from '@/services/realtime/types';
|
|
2842
|
+
|
|
2843
|
+
interface UseChannelReturn<T> {
|
|
2844
|
+
messages: WebSocketMessage<T>[];
|
|
2845
|
+
lastMessage: WebSocketMessage<T> | null;
|
|
2846
|
+
send: (type: string, payload: T) => void;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
export function useChannel<T = unknown>(
|
|
2850
|
+
manager: WebSocketManager,
|
|
2851
|
+
channel: string
|
|
2852
|
+
): UseChannelReturn<T> {
|
|
2853
|
+
const [messages, setMessages] = useState<WebSocketMessage<T>[]>([]);
|
|
2854
|
+
const [lastMessage, setLastMessage] = useState<WebSocketMessage<T> | null>(null);
|
|
2855
|
+
|
|
2856
|
+
useEffect(() => {
|
|
2857
|
+
const unsubscribe = manager.subscribe(channel, (message) => {
|
|
2858
|
+
const typed = message as WebSocketMessage<T>;
|
|
2859
|
+
setMessages((prev) => [...prev, typed]);
|
|
2860
|
+
setLastMessage(typed);
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
return unsubscribe;
|
|
2864
|
+
}, [manager, channel]);
|
|
2865
|
+
|
|
2866
|
+
const send = useCallback(
|
|
2867
|
+
(type: string, payload: T) => {
|
|
2868
|
+
manager.send(type, payload, channel);
|
|
2869
|
+
},
|
|
2870
|
+
[manager, channel]
|
|
2871
|
+
);
|
|
2872
|
+
|
|
2873
|
+
return { messages, lastMessage, send };
|
|
2874
|
+
}
|
|
2875
|
+
```
|
|
2876
|
+
|
|
2877
|
+
**Step 3: Create usePresence hook**
|
|
2878
|
+
|
|
2879
|
+
```typescript
|
|
2880
|
+
// hooks/usePresence.ts
|
|
2881
|
+
import { useEffect, useState } from 'react';
|
|
2882
|
+
import { WebSocketManager } from '@/services/realtime/websocket-manager';
|
|
2883
|
+
|
|
2884
|
+
interface PresenceUser {
|
|
2885
|
+
id: string;
|
|
2886
|
+
name?: string;
|
|
2887
|
+
lastSeen: number;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
interface UsePresenceReturn {
|
|
2891
|
+
onlineUsers: PresenceUser[];
|
|
2892
|
+
isUserOnline: (userId: string) => boolean;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
export function usePresence(
|
|
2896
|
+
manager: WebSocketManager,
|
|
2897
|
+
channel: string
|
|
2898
|
+
): UsePresenceReturn {
|
|
2899
|
+
const [onlineUsers, setOnlineUsers] = useState<PresenceUser[]>([]);
|
|
2900
|
+
|
|
2901
|
+
useEffect(() => {
|
|
2902
|
+
const unsubscribe = manager.subscribe(channel, (message) => {
|
|
2903
|
+
if (message.type === 'presence_join') {
|
|
2904
|
+
const user = message.payload as PresenceUser;
|
|
2905
|
+
setOnlineUsers((prev) => {
|
|
2906
|
+
const filtered = prev.filter((u) => u.id !== user.id);
|
|
2907
|
+
return [...filtered, user];
|
|
2908
|
+
});
|
|
2909
|
+
} else if (message.type === 'presence_leave') {
|
|
2910
|
+
const { id } = message.payload as { id: string };
|
|
2911
|
+
setOnlineUsers((prev) => prev.filter((u) => u.id !== id));
|
|
2912
|
+
} else if (message.type === 'presence_sync') {
|
|
2913
|
+
setOnlineUsers(message.payload as PresenceUser[]);
|
|
2914
|
+
}
|
|
2915
|
+
});
|
|
2916
|
+
|
|
2917
|
+
// Request current presence list
|
|
2918
|
+
manager.send('presence_sync', {}, channel);
|
|
2919
|
+
|
|
2920
|
+
return unsubscribe;
|
|
2921
|
+
}, [manager, channel]);
|
|
2922
|
+
|
|
2923
|
+
const isUserOnline = (userId: string) =>
|
|
2924
|
+
onlineUsers.some((u) => u.id === userId);
|
|
2925
|
+
|
|
2926
|
+
return { onlineUsers, isUserOnline };
|
|
2927
|
+
}
|
|
2928
|
+
```
|
|
2929
|
+
|
|
2930
|
+
**Step 4: Commit**
|
|
2931
|
+
|
|
2932
|
+
```bash
|
|
2933
|
+
git add hooks/useWebSocket.ts hooks/useChannel.ts hooks/usePresence.ts
|
|
2934
|
+
git commit -m "feat(realtime): add useWebSocket, useChannel, and usePresence hooks"
|
|
2935
|
+
```
|
|
2936
|
+
|
|
2937
|
+
---
|
|
2938
|
+
|
|
2939
|
+
## Task 18: Update app.config.ts with new permissions & schemes
|
|
2940
|
+
|
|
2941
|
+
**Files:**
|
|
2942
|
+
- Modify: `app.config.ts`
|
|
2943
|
+
|
|
2944
|
+
**Step 1: Add camera, location, contacts, media library permissions to Android/iOS config**
|
|
2945
|
+
|
|
2946
|
+
Add to the iOS config section:
|
|
2947
|
+
```typescript
|
|
2948
|
+
infoPlist: {
|
|
2949
|
+
NSCameraUsageDescription: 'This app uses the camera to take photos.',
|
|
2950
|
+
NSPhotoLibraryUsageDescription: 'This app accesses your photo library to select images.',
|
|
2951
|
+
NSLocationWhenInUseUsageDescription: 'This app uses your location to show nearby results.',
|
|
2952
|
+
NSContactsUsageDescription: 'This app accesses your contacts to find friends.',
|
|
2953
|
+
NSMicrophoneUsageDescription: 'This app uses the microphone to record audio.',
|
|
2954
|
+
}
|
|
2955
|
+
```
|
|
2956
|
+
|
|
2957
|
+
Add to the Android config section:
|
|
2958
|
+
```typescript
|
|
2959
|
+
permissions: [
|
|
2960
|
+
'CAMERA',
|
|
2961
|
+
'READ_CONTACTS',
|
|
2962
|
+
'ACCESS_FINE_LOCATION',
|
|
2963
|
+
'ACCESS_COARSE_LOCATION',
|
|
2964
|
+
'READ_MEDIA_IMAGES',
|
|
2965
|
+
'READ_MEDIA_VIDEO',
|
|
2966
|
+
'RECORD_AUDIO',
|
|
2967
|
+
]
|
|
2968
|
+
```
|
|
2969
|
+
|
|
2970
|
+
Add expo-camera plugin to plugins array:
|
|
2971
|
+
```typescript
|
|
2972
|
+
['expo-camera', { cameraPermission: 'This app uses the camera to take photos.' }],
|
|
2973
|
+
```
|
|
2974
|
+
|
|
2975
|
+
**Step 2: Add OAuth redirect scheme for social login**
|
|
2976
|
+
|
|
2977
|
+
Add to the app scheme config:
|
|
2978
|
+
```typescript
|
|
2979
|
+
scheme: `com.yourcompany.yourapp${appVariant === 'development' ? '.dev' : appVariant === 'preview' ? '.preview' : ''}`,
|
|
2980
|
+
```
|
|
2981
|
+
|
|
2982
|
+
**Step 3: Commit**
|
|
2983
|
+
|
|
2984
|
+
```bash
|
|
2985
|
+
git add app.config.ts
|
|
2986
|
+
git commit -m "chore: update app.config.ts with permissions, camera plugin, and OAuth scheme"
|
|
2987
|
+
```
|
|
2988
|
+
|
|
2989
|
+
---
|
|
2990
|
+
|
|
2991
|
+
## Task 19: Update config constants with new feature flags
|
|
2992
|
+
|
|
2993
|
+
**Files:**
|
|
2994
|
+
- Modify: `constants/config.ts`
|
|
2995
|
+
|
|
2996
|
+
**Step 1: Add feature flags and storage keys**
|
|
2997
|
+
|
|
2998
|
+
Add to `featureFlags`:
|
|
2999
|
+
```typescript
|
|
3000
|
+
ENABLE_SOCIAL_LOGIN: true,
|
|
3001
|
+
ENABLE_PAYMENTS: false, // Enable when payment adapter is configured
|
|
3002
|
+
```
|
|
3003
|
+
|
|
3004
|
+
Add to `storageKeys`:
|
|
3005
|
+
```typescript
|
|
3006
|
+
PERMISSION_PREFIX: '@permission_asked_',
|
|
3007
|
+
ANALYTICS_USER_ID: '@analytics_user_id',
|
|
3008
|
+
```
|
|
3009
|
+
|
|
3010
|
+
Add new config section:
|
|
3011
|
+
```typescript
|
|
3012
|
+
socialAuth: {
|
|
3013
|
+
google: {
|
|
3014
|
+
clientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID || '',
|
|
3015
|
+
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID || '',
|
|
3016
|
+
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID || '',
|
|
3017
|
+
},
|
|
3018
|
+
},
|
|
3019
|
+
```
|
|
3020
|
+
|
|
3021
|
+
**Step 2: Update .env.example**
|
|
3022
|
+
|
|
3023
|
+
Add:
|
|
3024
|
+
```env
|
|
3025
|
+
# Social Login
|
|
3026
|
+
EXPO_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id
|
|
3027
|
+
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=your-google-ios-client-id
|
|
3028
|
+
EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID=your-google-android-client-id
|
|
3029
|
+
```
|
|
3030
|
+
|
|
3031
|
+
**Step 3: Commit**
|
|
3032
|
+
|
|
3033
|
+
```bash
|
|
3034
|
+
git add constants/config.ts .env.example
|
|
3035
|
+
git commit -m "chore: add Phase 6 feature flags and social auth config"
|
|
3036
|
+
```
|
|
3037
|
+
|
|
3038
|
+
---
|
|
3039
|
+
|
|
3040
|
+
## Task 20: Update translations for new features
|
|
3041
|
+
|
|
3042
|
+
**Files:**
|
|
3043
|
+
- Modify: `i18n/locales/en.json` (and equivalent for other locales)
|
|
3044
|
+
|
|
3045
|
+
**Step 1: Add translations for permissions, social login, payments**
|
|
3046
|
+
|
|
3047
|
+
Add to English translations:
|
|
3048
|
+
```json
|
|
3049
|
+
{
|
|
3050
|
+
"permissions": {
|
|
3051
|
+
"camera": "Camera access is needed to take photos.",
|
|
3052
|
+
"location": "Location access is needed to show nearby results.",
|
|
3053
|
+
"contacts": "Contacts access is needed to find friends.",
|
|
3054
|
+
"mediaLibrary": "Photo library access is needed to select images.",
|
|
3055
|
+
"microphone": "Microphone access is needed to record audio.",
|
|
3056
|
+
"notifications": "Notification permission is needed to send you alerts.",
|
|
3057
|
+
"openSettings": "Open Settings",
|
|
3058
|
+
"allowAccess": "Allow Access",
|
|
3059
|
+
"blocked": "Permission was denied. Please enable it in Settings."
|
|
3060
|
+
},
|
|
3061
|
+
"socialAuth": {
|
|
3062
|
+
"continueWithGoogle": "Continue with Google",
|
|
3063
|
+
"continueWithApple": "Continue with Apple",
|
|
3064
|
+
"orContinueWith": "Or continue with"
|
|
3065
|
+
},
|
|
3066
|
+
"payments": {
|
|
3067
|
+
"upgradeToPremium": "Upgrade to Premium",
|
|
3068
|
+
"subscribe": "Subscribe",
|
|
3069
|
+
"restore": "Restore purchases",
|
|
3070
|
+
"perMonth": "/mo",
|
|
3071
|
+
"perYear": "/yr"
|
|
3072
|
+
},
|
|
3073
|
+
"upload": {
|
|
3074
|
+
"uploading": "Uploading...",
|
|
3075
|
+
"complete": "Complete",
|
|
3076
|
+
"failed": "Upload failed",
|
|
3077
|
+
"retry": "Retry",
|
|
3078
|
+
"addPhoto": "Add Photo"
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
```
|
|
3082
|
+
|
|
3083
|
+
**Step 2: Commit**
|
|
3084
|
+
|
|
3085
|
+
```bash
|
|
3086
|
+
git add i18n/
|
|
3087
|
+
git commit -m "feat(i18n): add translations for permissions, social login, payments, upload"
|
|
3088
|
+
```
|
|
3089
|
+
|
|
3090
|
+
---
|
|
3091
|
+
|
|
3092
|
+
## Task 21: Integrate AnalyticsProvider into root layout
|
|
3093
|
+
|
|
3094
|
+
**Files:**
|
|
3095
|
+
- Modify: `app/_layout.tsx`
|
|
3096
|
+
|
|
3097
|
+
**Step 1: Add AnalyticsProvider to the provider tree**
|
|
3098
|
+
|
|
3099
|
+
Import and wrap the root layout children with `AnalyticsProvider` (inside the QueryClientProvider, after ThemeProvider):
|
|
3100
|
+
|
|
3101
|
+
```tsx
|
|
3102
|
+
import { AnalyticsProvider } from '@/components/providers/AnalyticsProvider';
|
|
3103
|
+
|
|
3104
|
+
// In the provider tree:
|
|
3105
|
+
<AnalyticsProvider>
|
|
3106
|
+
{/* existing children */}
|
|
3107
|
+
</AnalyticsProvider>
|
|
3108
|
+
```
|
|
3109
|
+
|
|
3110
|
+
**Step 2: Commit**
|
|
3111
|
+
|
|
3112
|
+
```bash
|
|
3113
|
+
git add app/_layout.tsx
|
|
3114
|
+
git commit -m "feat(analytics): integrate AnalyticsProvider into root layout"
|
|
3115
|
+
```
|
|
3116
|
+
|
|
3117
|
+
---
|
|
3118
|
+
|
|
3119
|
+
## Task 22: Update login screen with social login buttons
|
|
3120
|
+
|
|
3121
|
+
**Files:**
|
|
3122
|
+
- Modify: `app/(public)/login.tsx`
|
|
3123
|
+
|
|
3124
|
+
**Step 1: Add SocialLoginButtons to login screen**
|
|
3125
|
+
|
|
3126
|
+
Import and add below the existing login form:
|
|
3127
|
+
```tsx
|
|
3128
|
+
import { SocialLoginButtons } from '@/components/auth/SocialLoginButtons';
|
|
3129
|
+
|
|
3130
|
+
// After the form, add:
|
|
3131
|
+
<View className="my-4 flex-row items-center gap-3">
|
|
3132
|
+
<View className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
|
3133
|
+
<Text className="text-muted-light dark:text-muted-dark text-sm">or</Text>
|
|
3134
|
+
<View className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
|
3135
|
+
</View>
|
|
3136
|
+
|
|
3137
|
+
<SocialLoginButtons
|
|
3138
|
+
onSuccess={async (result) => {
|
|
3139
|
+
// Handle social login success — send idToken to your backend
|
|
3140
|
+
// then sign in with the returned session
|
|
3141
|
+
}}
|
|
3142
|
+
/>
|
|
3143
|
+
```
|
|
3144
|
+
|
|
3145
|
+
**Step 2: Commit**
|
|
3146
|
+
|
|
3147
|
+
```bash
|
|
3148
|
+
git add app/(public)/login.tsx
|
|
3149
|
+
git commit -m "feat(social-login): add social login buttons to login screen"
|
|
3150
|
+
```
|
|
3151
|
+
|
|
3152
|
+
---
|
|
3153
|
+
|
|
3154
|
+
## Task 23: Update exports and documentation
|
|
3155
|
+
|
|
3156
|
+
**Files:**
|
|
3157
|
+
- Modify: `README.md` — Add Phase 6 features section
|
|
3158
|
+
- Modify: `CHANGELOG.md` — Add 3.0.0 entry
|
|
3159
|
+
|
|
3160
|
+
**Step 1: Update README**
|
|
3161
|
+
|
|
3162
|
+
Add Phase 6 feature descriptions under the features section.
|
|
3163
|
+
|
|
3164
|
+
**Step 2: Update CHANGELOG**
|
|
3165
|
+
|
|
3166
|
+
Add `## [3.0.0] - 2026-02-XX` with all new features listed.
|
|
3167
|
+
|
|
3168
|
+
**Step 3: Update package.json version**
|
|
3169
|
+
|
|
3170
|
+
```bash
|
|
3171
|
+
npm version minor --no-git-tag-version
|
|
3172
|
+
```
|
|
3173
|
+
|
|
3174
|
+
**Step 4: Commit**
|
|
3175
|
+
|
|
3176
|
+
```bash
|
|
3177
|
+
git add README.md CHANGELOG.md package.json
|
|
3178
|
+
git commit -m "docs: update README and CHANGELOG for Phase 6 (v3.0.0)"
|
|
3179
|
+
```
|
|
3180
|
+
|
|
3181
|
+
---
|
|
3182
|
+
|
|
3183
|
+
## Task 24: Run full test suite and verify build
|
|
3184
|
+
|
|
3185
|
+
**Step 1: Run existing tests**
|
|
3186
|
+
|
|
3187
|
+
```bash
|
|
3188
|
+
npx jest --coverage
|
|
3189
|
+
```
|
|
3190
|
+
|
|
3191
|
+
Expected: All existing tests pass, no regressions.
|
|
3192
|
+
|
|
3193
|
+
**Step 2: Run TypeScript check**
|
|
3194
|
+
|
|
3195
|
+
```bash
|
|
3196
|
+
npx tsc --noEmit
|
|
3197
|
+
```
|
|
3198
|
+
|
|
3199
|
+
Expected: No type errors.
|
|
3200
|
+
|
|
3201
|
+
**Step 3: Run lint**
|
|
3202
|
+
|
|
3203
|
+
```bash
|
|
3204
|
+
npx eslint . --ext .ts,.tsx
|
|
3205
|
+
```
|
|
3206
|
+
|
|
3207
|
+
Expected: No errors (warnings acceptable).
|
|
3208
|
+
|
|
3209
|
+
**Step 4: Verify Expo build**
|
|
3210
|
+
|
|
3211
|
+
```bash
|
|
3212
|
+
npx expo export --platform web
|
|
3213
|
+
```
|
|
3214
|
+
|
|
3215
|
+
Expected: Web export succeeds.
|
|
3216
|
+
|
|
3217
|
+
**Step 5: Commit any fixes if needed**
|
|
3218
|
+
|
|
3219
|
+
```bash
|
|
3220
|
+
git add -A
|
|
3221
|
+
git commit -m "fix: resolve Phase 6 integration issues"
|
|
3222
|
+
```
|