@croacroa/react-native-template 2.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -21
  10. package/README.md +446 -402
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -375
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -176
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
@@ -0,0 +1,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
+ ```