noboarding 0.1.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +515 -0
- package/REVENUECAT_SETUP.md +756 -0
- package/SETUP_GUIDE.md +873 -0
- package/cusomte_screens.md +1964 -0
- package/lib/OnboardingFlow.d.ts +3 -0
- package/lib/OnboardingFlow.js +235 -0
- package/lib/analytics.d.ts +25 -0
- package/lib/analytics.js +72 -0
- package/lib/api.d.ts +31 -0
- package/lib/api.js +149 -0
- package/lib/components/ElementRenderer.d.ts +13 -0
- package/lib/components/ElementRenderer.js +521 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +18 -0
- package/lib/types.d.ts +185 -0
- package/lib/types.js +2 -0
- package/lib/variableUtils.d.ts +17 -0
- package/lib/variableUtils.js +118 -0
- package/logic.md +2095 -0
- package/package.json +44 -0
- package/src/OnboardingFlow.tsx +276 -0
- package/src/analytics.ts +84 -0
- package/src/api.ts +173 -0
- package/src/components/ElementRenderer.tsx +627 -0
- package/src/index.ts +32 -0
- package/src/types.ts +242 -0
- package/src/variableUtils.ts +133 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1964 @@
|
|
|
1
|
+
# Custom Screens - Complete Implementation Guide
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
1. [Overview](#overview)
|
|
6
|
+
2. [How Custom Screens Work](#how-custom-screens-work)
|
|
7
|
+
3. [Quick Start](#quick-start)
|
|
8
|
+
4. [Component API Reference](#component-api-reference)
|
|
9
|
+
5. [Preview Mode](#preview-mode)
|
|
10
|
+
6. [Best Practices](#best-practices)
|
|
11
|
+
7. [Common Patterns](#common-patterns)
|
|
12
|
+
8. [Version Management](#version-management)
|
|
13
|
+
9. [Troubleshooting](#troubleshooting)
|
|
14
|
+
10. [Advanced Topics](#advanced-topics)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
Custom screens let you integrate your own React Native components into onboarding flows managed by our platform. You get complete control over complex features while still benefiting from our analytics, A/B testing, and flow management.
|
|
21
|
+
|
|
22
|
+
### When to Use Custom Screens
|
|
23
|
+
|
|
24
|
+
**✅ Perfect for:**
|
|
25
|
+
- Native device features (camera, biometrics, location, Face ID)
|
|
26
|
+
- Third-party SDK integrations (Stripe, Plaid, Auth0)
|
|
27
|
+
- Complex business logic unique to your app
|
|
28
|
+
- Existing screens you want to keep (gradual migration)
|
|
29
|
+
- Features requiring real-time API calls
|
|
30
|
+
- Advanced animations or custom interactions
|
|
31
|
+
|
|
32
|
+
**❌ Not recommended for:**
|
|
33
|
+
- Simple text and images (use SDK Text/Image components instead)
|
|
34
|
+
- Basic forms (use SDK TextInput component)
|
|
35
|
+
- Standard authentication (use SDK SocialLogin component)
|
|
36
|
+
- Features that can be built with SDK components
|
|
37
|
+
|
|
38
|
+
### Key Benefits
|
|
39
|
+
|
|
40
|
+
- ✅ **Full control** - Write any React Native code you want
|
|
41
|
+
- ✅ **Native access** - Use camera, location, biometrics, etc.
|
|
42
|
+
- ✅ **Your APIs** - Call your own backend services
|
|
43
|
+
- ✅ **Analytics included** - Automatic tracking of views, completions, drop-offs
|
|
44
|
+
- ✅ **A/B testable** - Test different positions in flow
|
|
45
|
+
- ✅ **No MAU charges** - Custom screens don't count toward your usage limits
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## How Custom Screens Work
|
|
50
|
+
|
|
51
|
+
### Architecture
|
|
52
|
+
|
|
53
|
+
Custom screens are React Native components that **live in your app code**, not on our servers. The platform simply tells the SDK when to render them.
|
|
54
|
+
```
|
|
55
|
+
┌─────────────────────────┐ ┌──────────────────────┐
|
|
56
|
+
│ Your App (Local) │ │ Platform (Remote) │
|
|
57
|
+
├─────────────────────────┤ ├──────────────────────┤
|
|
58
|
+
│ │ │ │
|
|
59
|
+
│ MealTrackerScreen.tsx │ ←───→ │ Dashboard Config: │
|
|
60
|
+
│ - Component code │ │ { │
|
|
61
|
+
│ - UI rendering │ │ "type": "custom", │
|
|
62
|
+
│ - API calls │ │ "name": "Meal..." │
|
|
63
|
+
│ - Business logic │ │ } │
|
|
64
|
+
│ │ │ │
|
|
65
|
+
└─────────────────────────┘ └──────────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**At runtime:**
|
|
69
|
+
```
|
|
70
|
+
User starts onboarding
|
|
71
|
+
↓
|
|
72
|
+
SDK fetches config from platform
|
|
73
|
+
↓
|
|
74
|
+
Config says: "Screen 3 = custom: MealTrackerScreen"
|
|
75
|
+
↓
|
|
76
|
+
SDK looks for MealTrackerScreen in customComponents
|
|
77
|
+
↓
|
|
78
|
+
SDK renders YOUR component
|
|
79
|
+
↓
|
|
80
|
+
Your component executes (calls your APIs, handles logic)
|
|
81
|
+
↓
|
|
82
|
+
User completes screen
|
|
83
|
+
↓
|
|
84
|
+
SDK continues to next screen
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
### What's Remotely Editable vs. What Requires App Updates
|
|
90
|
+
|
|
91
|
+
| Action | Requires App Update? | Update Time |
|
|
92
|
+
|--------|---------------------|-------------|
|
|
93
|
+
| **Reorder custom screen in flow** | ❌ No | Instant |
|
|
94
|
+
| **Remove custom screen from flow** | ❌ No | Instant |
|
|
95
|
+
| **Show/hide custom screen conditionally** | ❌ No | Instant |
|
|
96
|
+
| **Change SDK component properties** | ❌ No | Instant |
|
|
97
|
+
| **Edit custom screen code/UI** | ✅ Yes | 1-3 days (App Store review) |
|
|
98
|
+
| **Add new custom screen** | ✅ Yes | 1-3 days (App Store review) |
|
|
99
|
+
| **Fix bugs in custom screen** | ✅ Yes | 1-3 days (App Store review) |
|
|
100
|
+
|
|
101
|
+
**Example:**
|
|
102
|
+
```json
|
|
103
|
+
// ✅ This can change instantly (no app update):
|
|
104
|
+
{
|
|
105
|
+
"screens": [
|
|
106
|
+
{"type": "welcome_screen"},
|
|
107
|
+
{"type": "custom", "name": "MealTrackerScreen"}, // ← Can reorder
|
|
108
|
+
{"type": "goal_selector"}
|
|
109
|
+
],
|
|
110
|
+
"conditions": {
|
|
111
|
+
"show_meal_tracker": {
|
|
112
|
+
"if": {"variable": "wants_nutrition", "equals": true} // ← Can change
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
```typescript
|
|
118
|
+
// ❌ This requires App Store submission:
|
|
119
|
+
export const MealTrackerScreen = () => {
|
|
120
|
+
// ANY changes to this code require app update
|
|
121
|
+
const [calories, setCalories] = useState(null);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<View>
|
|
125
|
+
<Camera /> // ← Can't change this remotely
|
|
126
|
+
<Text>Calories: {calories}</Text> // ← Can't change this remotely
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Quick Start
|
|
135
|
+
|
|
136
|
+
### Step 1: Create the Component
|
|
137
|
+
|
|
138
|
+
Create your custom screen file:
|
|
139
|
+
```typescript
|
|
140
|
+
// screens/MealTrackerScreen.tsx
|
|
141
|
+
|
|
142
|
+
import React, { useState, useRef } from 'react';
|
|
143
|
+
import { View, Text, Button, Image, ActivityIndicator } from 'react-native';
|
|
144
|
+
import { Camera } from 'expo-camera';
|
|
145
|
+
|
|
146
|
+
export const MealTrackerScreen = ({
|
|
147
|
+
analytics, // Track events
|
|
148
|
+
onNext, // Go to next screen
|
|
149
|
+
onSkip, // Skip this screen (optional)
|
|
150
|
+
preview, // True when in dashboard preview
|
|
151
|
+
data, // Previously collected user data (optional)
|
|
152
|
+
onDataUpdate // Update collected data (optional)
|
|
153
|
+
}) => {
|
|
154
|
+
const [photo, setPhoto] = useState(null);
|
|
155
|
+
const [calories, setCalories] = useState(null);
|
|
156
|
+
const [loading, setLoading] = useState(false);
|
|
157
|
+
const cameraRef = useRef(null);
|
|
158
|
+
|
|
159
|
+
// Track screen view on mount
|
|
160
|
+
React.useEffect(() => {
|
|
161
|
+
analytics.track('screen_viewed', {
|
|
162
|
+
screen_id: 'meal_tracker',
|
|
163
|
+
screen_type: 'custom'
|
|
164
|
+
});
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
// Handle photo capture
|
|
168
|
+
const takePicture = async () => {
|
|
169
|
+
if (!cameraRef.current) return;
|
|
170
|
+
|
|
171
|
+
analytics.track('photo_taken');
|
|
172
|
+
|
|
173
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
174
|
+
quality: 0.7,
|
|
175
|
+
base64: true
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
setPhoto(photo);
|
|
179
|
+
await analyzePhoto(photo);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Analyze photo with YOUR API
|
|
183
|
+
const analyzePhoto = async (photo) => {
|
|
184
|
+
setLoading(true);
|
|
185
|
+
analytics.track('analysis_started');
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch('https://your-backend.com/api/analyze-meal', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: {
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
'Authorization': `Bearer ${YOUR_API_KEY}`
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
image: photo.base64,
|
|
196
|
+
userId: data?.userId // Use collected data if needed
|
|
197
|
+
})
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error('Analysis failed');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const result = await response.json();
|
|
205
|
+
setCalories(result.calories);
|
|
206
|
+
|
|
207
|
+
// Update collected data
|
|
208
|
+
onDataUpdate?.({
|
|
209
|
+
meal_calories: result.calories,
|
|
210
|
+
meal_timestamp: new Date().toISOString()
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
analytics.track('analysis_completed', {
|
|
214
|
+
calories: result.calories
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
analytics.track('analysis_failed', {
|
|
218
|
+
error: error.message
|
|
219
|
+
});
|
|
220
|
+
alert('Failed to analyze meal. Please try again.');
|
|
221
|
+
} finally {
|
|
222
|
+
setLoading(false);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Handle continue
|
|
227
|
+
const handleContinue = () => {
|
|
228
|
+
analytics.track('screen_completed', {
|
|
229
|
+
calories: calories
|
|
230
|
+
});
|
|
231
|
+
onNext();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// PREVIEW MODE (for dashboard)
|
|
235
|
+
if (preview) {
|
|
236
|
+
return (
|
|
237
|
+
<View style={{ padding: 20, alignItems: 'center' }}>
|
|
238
|
+
<View style={{
|
|
239
|
+
width: 300,
|
|
240
|
+
height: 300,
|
|
241
|
+
backgroundColor: '#f5f5f5',
|
|
242
|
+
borderRadius: 16,
|
|
243
|
+
justifyContent: 'center',
|
|
244
|
+
alignItems: 'center',
|
|
245
|
+
marginBottom: 24
|
|
246
|
+
}}>
|
|
247
|
+
<Text style={{ fontSize: 64 }}>📸</Text>
|
|
248
|
+
<Text style={{ marginTop: 12, fontSize: 16, color: '#666' }}>
|
|
249
|
+
Camera Preview
|
|
250
|
+
</Text>
|
|
251
|
+
<Text style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
|
252
|
+
(Only works in real app)
|
|
253
|
+
</Text>
|
|
254
|
+
</View>
|
|
255
|
+
|
|
256
|
+
<View style={{
|
|
257
|
+
padding: 16,
|
|
258
|
+
backgroundColor: '#fff',
|
|
259
|
+
borderRadius: 12,
|
|
260
|
+
width: '100%',
|
|
261
|
+
marginBottom: 20,
|
|
262
|
+
borderWidth: 1,
|
|
263
|
+
borderColor: '#e0e0e0'
|
|
264
|
+
}}>
|
|
265
|
+
<Text style={{ fontSize: 20, fontWeight: '600' }}>
|
|
266
|
+
Estimated Calories: 450
|
|
267
|
+
</Text>
|
|
268
|
+
<Text style={{ fontSize: 14, color: '#666', marginTop: 4 }}>
|
|
269
|
+
Mock data for preview
|
|
270
|
+
</Text>
|
|
271
|
+
</View>
|
|
272
|
+
|
|
273
|
+
<Button title="Continue" onPress={onNext} />
|
|
274
|
+
</View>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// REAL IMPLEMENTATION
|
|
279
|
+
|
|
280
|
+
// Before taking photo
|
|
281
|
+
if (!photo) {
|
|
282
|
+
return (
|
|
283
|
+
<View style={{ flex: 1 }}>
|
|
284
|
+
<Camera
|
|
285
|
+
ref={cameraRef}
|
|
286
|
+
style={{ flex: 1 }}
|
|
287
|
+
type={Camera.Constants.Type.back}
|
|
288
|
+
>
|
|
289
|
+
<View style={{
|
|
290
|
+
flex: 1,
|
|
291
|
+
backgroundColor: 'transparent',
|
|
292
|
+
justifyContent: 'flex-end',
|
|
293
|
+
padding: 20
|
|
294
|
+
}}>
|
|
295
|
+
<Button title="Take Photo" onPress={takePicture} />
|
|
296
|
+
{onSkip && (
|
|
297
|
+
<Button title="Skip" onPress={onSkip} color="#666" />
|
|
298
|
+
)}
|
|
299
|
+
</View>
|
|
300
|
+
</Camera>
|
|
301
|
+
</View>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// After taking photo
|
|
306
|
+
return (
|
|
307
|
+
<View style={{ flex: 1, padding: 20, alignItems: 'center' }}>
|
|
308
|
+
<Image
|
|
309
|
+
source={{ uri: photo.uri }}
|
|
310
|
+
style={{ width: 300, height: 300, borderRadius: 16 }}
|
|
311
|
+
/>
|
|
312
|
+
|
|
313
|
+
{loading ? (
|
|
314
|
+
<View style={{ marginTop: 32, alignItems: 'center' }}>
|
|
315
|
+
<ActivityIndicator size="large" color="#FF6B6B" />
|
|
316
|
+
<Text style={{ marginTop: 12, fontSize: 16 }}>
|
|
317
|
+
Analyzing your meal...
|
|
318
|
+
</Text>
|
|
319
|
+
</View>
|
|
320
|
+
) : calories ? (
|
|
321
|
+
<View style={{ marginTop: 32, alignItems: 'center', width: '100%' }}>
|
|
322
|
+
<Text style={{ fontSize: 28, fontWeight: 'bold' }}>
|
|
323
|
+
Estimated Calories: {calories}
|
|
324
|
+
</Text>
|
|
325
|
+
<View style={{ marginTop: 24, width: '100%', gap: 12 }}>
|
|
326
|
+
<Button title="Continue" onPress={handleContinue} />
|
|
327
|
+
<Button
|
|
328
|
+
title="Retake Photo"
|
|
329
|
+
onPress={() => setPhoto(null)}
|
|
330
|
+
color="#666"
|
|
331
|
+
/>
|
|
332
|
+
</View>
|
|
333
|
+
</View>
|
|
334
|
+
) : null}
|
|
335
|
+
</View>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
### Step 2: Register the Component
|
|
343
|
+
|
|
344
|
+
Register your custom screen with the SDK:
|
|
345
|
+
```typescript
|
|
346
|
+
// App.tsx
|
|
347
|
+
|
|
348
|
+
import React, { useState } from 'react';
|
|
349
|
+
import { OnboardingFlow } from '@yourplatform/sdk';
|
|
350
|
+
import { MealTrackerScreen } from './screens/MealTrackerScreen';
|
|
351
|
+
import { WorkoutLogScreen } from './screens/WorkoutLogScreen';
|
|
352
|
+
|
|
353
|
+
export default function App() {
|
|
354
|
+
const [showOnboarding, setShowOnboarding] = useState(true);
|
|
355
|
+
|
|
356
|
+
if (showOnboarding) {
|
|
357
|
+
return (
|
|
358
|
+
<OnboardingFlow
|
|
359
|
+
apiKey="sk_live_abc123..."
|
|
360
|
+
|
|
361
|
+
// Register custom screens here
|
|
362
|
+
customComponents={{
|
|
363
|
+
MealTrackerScreen: MealTrackerScreen,
|
|
364
|
+
WorkoutLogScreen: WorkoutLogScreen,
|
|
365
|
+
// Add more custom screens as needed
|
|
366
|
+
}}
|
|
367
|
+
|
|
368
|
+
onComplete={(userData) => {
|
|
369
|
+
// userData contains both SDK and custom screen data
|
|
370
|
+
console.log('Onboarding completed:', userData);
|
|
371
|
+
|
|
372
|
+
// Save to your database
|
|
373
|
+
await saveUserProfile(userData);
|
|
374
|
+
|
|
375
|
+
// Hide onboarding
|
|
376
|
+
setShowOnboarding(false);
|
|
377
|
+
}}
|
|
378
|
+
|
|
379
|
+
onSkip={() => {
|
|
380
|
+
setShowOnboarding(false);
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return <MainApp />;
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
### Step 3: Add to Dashboard Flow
|
|
393
|
+
|
|
394
|
+
1. **Go to Flow Builder** in the dashboard
|
|
395
|
+
2. **Click "🛠️ Add Custom Screen"**
|
|
396
|
+
3. **Enter details:**
|
|
397
|
+
- **Component Name:** `MealTrackerScreen` (must match exactly)
|
|
398
|
+
- **Description:** "Takes photo of meal and estimates calories"
|
|
399
|
+
- **Minimum App Version (optional):** `1.1.0`
|
|
400
|
+
4. **Position the screen** in your flow by dragging
|
|
401
|
+
5. **Click "Save Draft"**
|
|
402
|
+
|
|
403
|
+
**Dashboard will show:**
|
|
404
|
+
```
|
|
405
|
+
┌────────────────────────────────────────────────┐
|
|
406
|
+
│ Onboarding Flow │
|
|
407
|
+
├────────────────────────────────────────────────┤
|
|
408
|
+
│ 1. Welcome Screen (SDK) ✓ │
|
|
409
|
+
│ [Edit] [Delete] [↑] [↓] │
|
|
410
|
+
│ │
|
|
411
|
+
│ 2. Meal Tracker 🔒 (Custom) │
|
|
412
|
+
│ Component: MealTrackerScreen │
|
|
413
|
+
│ [View Only] [Delete] [↑] [↓] │
|
|
414
|
+
│ │
|
|
415
|
+
│ ⚠️ This screen requires app v1.1.0+ │
|
|
416
|
+
│ Custom screens require App Store approval │
|
|
417
|
+
│ to modify. You can reorder or remove. │
|
|
418
|
+
│ │
|
|
419
|
+
│ 3. Goal Selector (SDK) ✓ │
|
|
420
|
+
│ [Edit] [Delete] [↑] [↓] │
|
|
421
|
+
└────────────────────────────────────────────────┘
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
### Step 4: Deploy Your App
|
|
427
|
+
|
|
428
|
+
1. **Test locally** first with Expo Go or development build
|
|
429
|
+
2. **Build production app** with the custom screen included
|
|
430
|
+
3. **Submit to App Store / Google Play**
|
|
431
|
+
4. **Wait for approval** (typically 1-3 days)
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
### Step 5: Publish the Flow
|
|
436
|
+
|
|
437
|
+
**After your app is approved and live:**
|
|
438
|
+
|
|
439
|
+
1. Go back to dashboard
|
|
440
|
+
2. Click **"Publish"**
|
|
441
|
+
3. Users will now see the custom screen in their onboarding
|
|
442
|
+
|
|
443
|
+
**⚠️ Important:** Don't publish the flow until your app with the custom screen is live in the stores. Otherwise users will encounter errors.
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Component API Reference
|
|
448
|
+
|
|
449
|
+
### Props Interface
|
|
450
|
+
```typescript
|
|
451
|
+
interface CustomScreenProps {
|
|
452
|
+
/**
|
|
453
|
+
* Analytics tracking object
|
|
454
|
+
* Use to track events, errors, and user behavior
|
|
455
|
+
*/
|
|
456
|
+
analytics: {
|
|
457
|
+
track: (eventName: string, properties?: Record<string, any>) => void;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Navigate to the next screen in the flow
|
|
462
|
+
* REQUIRED: Call this when user completes your screen
|
|
463
|
+
*/
|
|
464
|
+
onNext: () => void;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Skip this screen (optional)
|
|
468
|
+
* Only provided if screen is configured as skippable
|
|
469
|
+
*/
|
|
470
|
+
onSkip?: () => void;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Preview mode flag
|
|
474
|
+
* True when rendering in dashboard preview
|
|
475
|
+
* Use to show placeholder UI instead of real functionality
|
|
476
|
+
*/
|
|
477
|
+
preview?: boolean;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Previously collected user data (optional)
|
|
481
|
+
* Contains data from SDK components and other custom screens
|
|
482
|
+
*/
|
|
483
|
+
data?: Record<string, any>;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Update the collected user data (optional)
|
|
487
|
+
* Merge new data that will be passed to onComplete
|
|
488
|
+
*/
|
|
489
|
+
onDataUpdate?: (newData: Record<string, any>) => void;
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
### Analytics Tracking
|
|
496
|
+
|
|
497
|
+
Track events to understand user behavior and debug issues:
|
|
498
|
+
```typescript
|
|
499
|
+
// ✅ Always track screen view on mount
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
analytics.track('screen_viewed', {
|
|
502
|
+
screen_id: 'meal_tracker',
|
|
503
|
+
screen_type: 'custom'
|
|
504
|
+
});
|
|
505
|
+
}, []);
|
|
506
|
+
|
|
507
|
+
// ✅ Track user actions
|
|
508
|
+
analytics.track('button_clicked', {
|
|
509
|
+
button_name: 'take_photo'
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
analytics.track('photo_captured', {
|
|
513
|
+
quality: 0.7,
|
|
514
|
+
timestamp: Date.now()
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ✅ Track completion
|
|
518
|
+
analytics.track('screen_completed', {
|
|
519
|
+
screen_id: 'meal_tracker',
|
|
520
|
+
calories_analyzed: 450,
|
|
521
|
+
time_spent_seconds: 45
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ✅ Track skip
|
|
525
|
+
analytics.track('screen_skipped', {
|
|
526
|
+
screen_id: 'meal_tracker',
|
|
527
|
+
reason: 'user_declined'
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ✅ Track errors
|
|
531
|
+
analytics.track('error_occurred', {
|
|
532
|
+
error_type: 'api_failure',
|
|
533
|
+
error_message: 'Network timeout',
|
|
534
|
+
endpoint: '/api/analyze-meal'
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ✅ Track API calls
|
|
538
|
+
analytics.track('api_request_started', {
|
|
539
|
+
endpoint: '/analyze-meal'
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
analytics.track('api_request_completed', {
|
|
543
|
+
endpoint: '/analyze-meal',
|
|
544
|
+
duration_ms: 2340,
|
|
545
|
+
status_code: 200
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**These events appear in your dashboard analytics:**
|
|
550
|
+
|
|
551
|
+
- Filter by screen_id
|
|
552
|
+
- See conversion funnels
|
|
553
|
+
- Compare custom vs SDK screen performance
|
|
554
|
+
- A/B test custom screen positioning
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
### Navigation
|
|
559
|
+
|
|
560
|
+
**Always call `onNext()` when complete:**
|
|
561
|
+
```typescript
|
|
562
|
+
const handleContinue = () => {
|
|
563
|
+
// Track completion first
|
|
564
|
+
analytics.track('screen_completed', {
|
|
565
|
+
screen_id: 'meal_tracker',
|
|
566
|
+
data_collected: true
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Then navigate
|
|
570
|
+
onNext();
|
|
571
|
+
};
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
**Call `onSkip()` if user skips (if provided):**
|
|
575
|
+
```typescript
|
|
576
|
+
const handleSkip = () => {
|
|
577
|
+
analytics.track('screen_skipped', {
|
|
578
|
+
screen_id: 'meal_tracker'
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Safe call - onSkip might be undefined
|
|
582
|
+
onSkip?.();
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// Or with button
|
|
586
|
+
{onSkip && (
|
|
587
|
+
<Button title="Skip" onPress={handleSkip} />
|
|
588
|
+
)}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
### Data Collection
|
|
594
|
+
|
|
595
|
+
Custom screens can contribute to the final `userData` object:
|
|
596
|
+
```typescript
|
|
597
|
+
export const GoalSelectionScreen = ({
|
|
598
|
+
analytics,
|
|
599
|
+
onNext,
|
|
600
|
+
data,
|
|
601
|
+
onDataUpdate
|
|
602
|
+
}) => {
|
|
603
|
+
const [selectedGoals, setSelectedGoals] = useState([]);
|
|
604
|
+
|
|
605
|
+
const handleContinue = () => {
|
|
606
|
+
// Add to collected data
|
|
607
|
+
onDataUpdate?.({
|
|
608
|
+
fitness_goals: selectedGoals,
|
|
609
|
+
goals_count: selectedGoals.length,
|
|
610
|
+
goals_timestamp: new Date().toISOString()
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
analytics.track('goals_selected', {
|
|
614
|
+
count: selectedGoals.length,
|
|
615
|
+
goals: selectedGoals
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
onNext();
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<View>
|
|
623
|
+
<CheckboxGroup
|
|
624
|
+
options={['Lose Weight', 'Build Muscle', 'Improve Endurance']}
|
|
625
|
+
selected={selectedGoals}
|
|
626
|
+
onChange={setSelectedGoals}
|
|
627
|
+
/>
|
|
628
|
+
<Button title="Continue" onPress={handleContinue} />
|
|
629
|
+
</View>
|
|
630
|
+
);
|
|
631
|
+
};
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
**The final `userData` merges SDK and custom screen data:**
|
|
635
|
+
```typescript
|
|
636
|
+
<OnboardingFlow
|
|
637
|
+
customComponents={{ GoalSelectionScreen }}
|
|
638
|
+
onComplete={(userData) => {
|
|
639
|
+
// Contains data from both SDK and custom screens:
|
|
640
|
+
console.log(userData);
|
|
641
|
+
// {
|
|
642
|
+
// name: "John", // From SDK TextInput
|
|
643
|
+
// age: 25, // From SDK TextInput
|
|
644
|
+
// email: "john@example.com", // From SDK TextInput
|
|
645
|
+
// fitness_goals: ["Build Muscle"], // From custom screen
|
|
646
|
+
// goals_count: 1, // From custom screen
|
|
647
|
+
// goals_timestamp: "2025-02-17..." // From custom screen
|
|
648
|
+
// }
|
|
649
|
+
}}
|
|
650
|
+
/>
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
---
|
|
654
|
+
|
|
655
|
+
### Accessing Previous Data
|
|
656
|
+
|
|
657
|
+
Use the `data` prop to access previously collected information:
|
|
658
|
+
```typescript
|
|
659
|
+
export const SummaryScreen = ({ analytics, onNext, data }) => {
|
|
660
|
+
return (
|
|
661
|
+
<View>
|
|
662
|
+
<Text>Welcome, {data?.name}!</Text>
|
|
663
|
+
<Text>Age: {data?.age}</Text>
|
|
664
|
+
<Text>Goals: {data?.fitness_goals?.join(', ')}</Text>
|
|
665
|
+
|
|
666
|
+
<Button title="Confirm" onPress={onNext} />
|
|
667
|
+
</View>
|
|
668
|
+
);
|
|
669
|
+
};
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Preview Mode
|
|
675
|
+
|
|
676
|
+
### Why Preview Mode is Critical
|
|
677
|
+
|
|
678
|
+
Custom screens often use native features that **don't work in the browser**:
|
|
679
|
+
|
|
680
|
+
- ❌ Camera
|
|
681
|
+
- ❌ Location/GPS
|
|
682
|
+
- ❌ Biometrics (Face ID, Touch ID)
|
|
683
|
+
- ❌ Bluetooth
|
|
684
|
+
- ❌ Push notifications
|
|
685
|
+
- ❌ Native modules
|
|
686
|
+
|
|
687
|
+
**Preview mode lets you show a placeholder in the dashboard** while providing full functionality in the real app.
|
|
688
|
+
|
|
689
|
+
---
|
|
690
|
+
|
|
691
|
+
### Implementing Preview Mode
|
|
692
|
+
|
|
693
|
+
<!-- **Always check the `preview` prop:**
|
|
694
|
+
```typescript
|
|
695
|
+
export const CameraScreen = ({ analytics, onNext, preview }) => {
|
|
696
|
+
// PREVIEW MODE (dashboard - no camera access)
|
|
697
|
+
if (preview) {
|
|
698
|
+
return (
|
|
699
|
+
<View style={styles.previewContainer}>
|
|
700
|
+
<View style={styles.mockCamera}>
|
|
701
|
+
<Text style={styles.cameraIcon}>📸</Text>
|
|
702
|
+
<Text style={styles.previewLabel}>Camera Preview</Text>
|
|
703
|
+
<Text style={styles.previewNote}>
|
|
704
|
+
(Real camera only works in app)
|
|
705
|
+
</Text>
|
|
706
|
+
</View>
|
|
707
|
+
|
|
708
|
+
<View style={styles.mockResult}>
|
|
709
|
+
<Text style={styles.resultText}>
|
|
710
|
+
Result: Success (mock data)
|
|
711
|
+
</Text>
|
|
712
|
+
</View>
|
|
713
|
+
|
|
714
|
+
<Button title="Continue" onPress={onNext} />
|
|
715
|
+
</View>
|
|
716
|
+
);
|
|
717
|
+
} -->
|
|
718
|
+
|
|
719
|
+
// REAL IMPLEMENTATION (mobile app - full camera access)
|
|
720
|
+
return (
|
|
721
|
+
<Camera>
|
|
722
|
+
{/* Real camera implementation */}
|
|
723
|
+
</Camera>
|
|
724
|
+
);
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const styles = StyleSheet.create({
|
|
728
|
+
previewContainer: {
|
|
729
|
+
flex: 1,
|
|
730
|
+
padding: 20,
|
|
731
|
+
alignItems: 'center',
|
|
732
|
+
justifyContent: 'center'
|
|
733
|
+
},
|
|
734
|
+
mockCamera: {
|
|
735
|
+
width: 300,
|
|
736
|
+
height: 400,
|
|
737
|
+
backgroundColor: '#f5f5f5',
|
|
738
|
+
borderRadius: 16,
|
|
739
|
+
justifyContent: 'center',
|
|
740
|
+
alignItems: 'center',
|
|
741
|
+
marginBottom: 24
|
|
742
|
+
},
|
|
743
|
+
cameraIcon: {
|
|
744
|
+
fontSize: 64
|
|
745
|
+
},
|
|
746
|
+
previewLabel: {
|
|
747
|
+
marginTop: 16,
|
|
748
|
+
fontSize: 18,
|
|
749
|
+
fontWeight: '600',
|
|
750
|
+
color: '#333'
|
|
751
|
+
},
|
|
752
|
+
previewNote: {
|
|
753
|
+
marginTop: 8,
|
|
754
|
+
fontSize: 14,
|
|
755
|
+
color: '#999'
|
|
756
|
+
},
|
|
757
|
+
mockResult: {
|
|
758
|
+
padding: 16,
|
|
759
|
+
backgroundColor: '#fff',
|
|
760
|
+
borderRadius: 12,
|
|
761
|
+
borderWidth: 1,
|
|
762
|
+
borderColor: '#e0e0e0',
|
|
763
|
+
marginBottom: 24
|
|
764
|
+
},
|
|
765
|
+
resultText: {
|
|
766
|
+
fontSize: 16,
|
|
767
|
+
fontWeight: '500'
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
---
|
|
773
|
+
|
|
774
|
+
### Preview Best Practices
|
|
775
|
+
|
|
776
|
+
**✅ DO:**
|
|
777
|
+
|
|
778
|
+
- Show a visual placeholder that represents the screen's purpose
|
|
779
|
+
- Use icons/emojis to indicate functionality (📸 for camera, 📍 for location)
|
|
780
|
+
- Display mock data to demonstrate the UI flow
|
|
781
|
+
- Keep the same layout structure as the real screen
|
|
782
|
+
- Include Continue/Skip buttons so preview flow continues
|
|
783
|
+
- Show helpful text like "(Only works in real app)"
|
|
784
|
+
|
|
785
|
+
**❌ DON'T:**
|
|
786
|
+
|
|
787
|
+
- Return `null` or empty view (breaks preview flow)
|
|
788
|
+
- Show error messages or warnings
|
|
789
|
+
- Try to access native APIs in preview mode
|
|
790
|
+
- Make preview UI completely different from real UI
|
|
791
|
+
- Forget to handle the preview prop
|
|
792
|
+
|
|
793
|
+
---
|
|
794
|
+
|
|
795
|
+
### Preview Examples
|
|
796
|
+
|
|
797
|
+
**Location Request:**
|
|
798
|
+
```typescript
|
|
799
|
+
if (preview) {
|
|
800
|
+
return (
|
|
801
|
+
<View style={{ padding: 20, alignItems: 'center' }}>
|
|
802
|
+
<Text style={{ fontSize: 64, marginBottom: 16 }}>📍</Text>
|
|
803
|
+
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
|
|
804
|
+
Location Access
|
|
805
|
+
</Text>
|
|
806
|
+
<Text style={{ fontSize: 16, color: '#666', textAlign: 'center' }}>
|
|
807
|
+
This screen requests location permission
|
|
808
|
+
</Text>
|
|
809
|
+
<Text style={{ fontSize: 14, color: '#999', marginTop: 8 }}>
|
|
810
|
+
(Preview mode - permission not actually requested)
|
|
811
|
+
</Text>
|
|
812
|
+
<View style={{ marginTop: 32, width: '100%' }}>
|
|
813
|
+
<Button title="Grant Permission (Mock)" onPress={onNext} />
|
|
814
|
+
<Button title="Skip" onPress={onSkip} color="#666" />
|
|
815
|
+
</View>
|
|
816
|
+
</View>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
**Payment/Checkout:**
|
|
822
|
+
```typescript
|
|
823
|
+
if (preview) {
|
|
824
|
+
return (
|
|
825
|
+
<View style={{ padding: 20 }}>
|
|
826
|
+
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
|
|
827
|
+
Complete Purchase
|
|
828
|
+
</Text>
|
|
829
|
+
|
|
830
|
+
<View style={{
|
|
831
|
+
padding: 16,
|
|
832
|
+
backgroundColor: '#f5f5f5',
|
|
833
|
+
borderRadius: 12,
|
|
834
|
+
marginBottom: 24
|
|
835
|
+
}}>
|
|
836
|
+
<Text style={{ fontSize: 16, marginBottom: 8 }}>
|
|
837
|
+
💳 Payment Form (Mock)
|
|
838
|
+
</Text>
|
|
839
|
+
<Text style={{ color: '#666' }}>
|
|
840
|
+
Card Number: •••• •••• •••• 1234
|
|
841
|
+
</Text>
|
|
842
|
+
<Text style={{ color: '#666' }}>
|
|
843
|
+
Expiry: 12/25
|
|
844
|
+
</Text>
|
|
845
|
+
</View>
|
|
846
|
+
|
|
847
|
+
<View style={{
|
|
848
|
+
padding: 16,
|
|
849
|
+
backgroundColor: '#e8f5e9',
|
|
850
|
+
borderRadius: 12,
|
|
851
|
+
marginBottom: 24
|
|
852
|
+
}}>
|
|
853
|
+
<Text style={{ color: '#2e7d32', fontWeight: '600' }}>
|
|
854
|
+
✓ Payment Successful (Mock)
|
|
855
|
+
</Text>
|
|
856
|
+
</View>
|
|
857
|
+
|
|
858
|
+
<Button title="Continue" onPress={onNext} />
|
|
859
|
+
</View>
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
---
|
|
865
|
+
|
|
866
|
+
## Best Practices
|
|
867
|
+
|
|
868
|
+
### 1. File Organization
|
|
869
|
+
|
|
870
|
+
**Simple screens (one file):**
|
|
871
|
+
```
|
|
872
|
+
/src
|
|
873
|
+
/screens
|
|
874
|
+
/custom
|
|
875
|
+
MealTrackerScreen.tsx
|
|
876
|
+
BiometricAuthScreen.tsx
|
|
877
|
+
LocationPermissionScreen.tsx
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
**Complex screens (folder structure):**
|
|
881
|
+
```
|
|
882
|
+
/src
|
|
883
|
+
/screens
|
|
884
|
+
/custom
|
|
885
|
+
/meal-tracker
|
|
886
|
+
index.tsx ← Export main component
|
|
887
|
+
MealTrackerScreen.tsx ← Main component
|
|
888
|
+
CameraView.tsx ← Sub-component
|
|
889
|
+
ResultView.tsx ← Sub-component
|
|
890
|
+
api.ts ← API calls
|
|
891
|
+
hooks.ts ← Custom hooks
|
|
892
|
+
utils.ts ← Helper functions
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
**Main export (index.tsx):**
|
|
896
|
+
```typescript
|
|
897
|
+
export { MealTrackerScreen } from './MealTrackerScreen';
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
### 2. Error Handling
|
|
903
|
+
|
|
904
|
+
**Always handle errors gracefully:**
|
|
905
|
+
```typescript
|
|
906
|
+
const analyzePhoto = async (photo) => {
|
|
907
|
+
setLoading(true);
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
const response = await fetch('https://your-api.com/analyze', {
|
|
911
|
+
method: 'POST',
|
|
912
|
+
headers: {
|
|
913
|
+
'Content-Type': 'application/json',
|
|
914
|
+
'Authorization': `Bearer ${API_KEY}`
|
|
915
|
+
},
|
|
916
|
+
body: JSON.stringify({ image: photo.base64 }),
|
|
917
|
+
timeout: 30000 // 30 second timeout
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
if (!response.ok) {
|
|
921
|
+
const errorData = await response.json();
|
|
922
|
+
throw new Error(errorData.message || `HTTP ${response.status}`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const data = await response.json();
|
|
926
|
+
|
|
927
|
+
if (!data.calories) {
|
|
928
|
+
throw new Error('Invalid response from server');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
setCalories(data.calories);
|
|
932
|
+
|
|
933
|
+
analytics.track('analysis_completed', {
|
|
934
|
+
calories: data.calories,
|
|
935
|
+
duration_ms: Date.now() - startTime
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
} catch (error) {
|
|
939
|
+
// Track error
|
|
940
|
+
analytics.track('analysis_failed', {
|
|
941
|
+
error_type: error.name,
|
|
942
|
+
error_message: error.message,
|
|
943
|
+
stack_trace: error.stack
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// Show user-friendly message
|
|
947
|
+
Alert.alert(
|
|
948
|
+
'Analysis Failed',
|
|
949
|
+
'We couldn\'t analyze your meal. Please try again or skip this step.',
|
|
950
|
+
[
|
|
951
|
+
{ text: 'Retry', onPress: () => analyzePhoto(photo) },
|
|
952
|
+
{ text: 'Skip', onPress: onSkip, style: 'cancel' }
|
|
953
|
+
]
|
|
954
|
+
);
|
|
955
|
+
} finally {
|
|
956
|
+
setLoading(false);
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
### 3. Loading States
|
|
964
|
+
|
|
965
|
+
**Show clear, informative loading states:**
|
|
966
|
+
```typescript
|
|
967
|
+
const [loading, setLoading] = useState(false);
|
|
968
|
+
const [loadingMessage, setLoadingMessage] = useState('');
|
|
969
|
+
|
|
970
|
+
if (loading) {
|
|
971
|
+
return (
|
|
972
|
+
<View style={styles.loadingContainer}>
|
|
973
|
+
<ActivityIndicator size="large" color="#FF6B6B" />
|
|
974
|
+
|
|
975
|
+
<Text style={styles.loadingTitle}>
|
|
976
|
+
{loadingMessage || 'Please wait...'}
|
|
977
|
+
</Text>
|
|
978
|
+
|
|
979
|
+
<Text style={styles.loadingSubtitle}>
|
|
980
|
+
This may take a few seconds
|
|
981
|
+
</Text>
|
|
982
|
+
|
|
983
|
+
{/* Optional: Progress indicator */}
|
|
984
|
+
<View style={styles.progressBar}>
|
|
985
|
+
<View style={[styles.progress, { width: `${progress}%` }]} />
|
|
986
|
+
</View>
|
|
987
|
+
</View>
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// When calling API
|
|
992
|
+
setLoading(true);
|
|
993
|
+
setLoadingMessage('Analyzing your meal...');
|
|
994
|
+
await analyzePhoto(photo);
|
|
995
|
+
setLoading(false);
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
---
|
|
999
|
+
|
|
1000
|
+
### 4. Accessibility
|
|
1001
|
+
|
|
1002
|
+
**Make custom screens accessible:**
|
|
1003
|
+
```typescript
|
|
1004
|
+
import { AccessibilityInfo } from 'react-native';
|
|
1005
|
+
|
|
1006
|
+
export const MealTrackerScreen = ({ analytics, onNext }) => {
|
|
1007
|
+
useEffect(() => {
|
|
1008
|
+
// Announce screen to screen readers
|
|
1009
|
+
AccessibilityInfo.announceForAccessibility(
|
|
1010
|
+
'Meal tracker screen. Take a photo of your meal to estimate calories.'
|
|
1011
|
+
);
|
|
1012
|
+
}, []);
|
|
1013
|
+
|
|
1014
|
+
return (
|
|
1015
|
+
<View>
|
|
1016
|
+
<TouchableOpacity
|
|
1017
|
+
accessible={true}
|
|
1018
|
+
accessibilityRole="button"
|
|
1019
|
+
accessibilityLabel="Take photo of meal"
|
|
1020
|
+
accessibilityHint="Opens camera to capture an image of your meal"
|
|
1021
|
+
onPress={takePicture}
|
|
1022
|
+
>
|
|
1023
|
+
<Text>Take Photo</Text>
|
|
1024
|
+
</TouchableOpacity>
|
|
1025
|
+
|
|
1026
|
+
<TouchableOpacity
|
|
1027
|
+
accessible={true}
|
|
1028
|
+
accessibilityRole="button"
|
|
1029
|
+
accessibilityLabel="Skip meal tracking"
|
|
1030
|
+
accessibilityHint="Continues to next screen without taking photo"
|
|
1031
|
+
onPress={onSkip}
|
|
1032
|
+
>
|
|
1033
|
+
<Text>Skip</Text>
|
|
1034
|
+
</TouchableOpacity>
|
|
1035
|
+
</View>
|
|
1036
|
+
);
|
|
1037
|
+
};
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
---
|
|
1041
|
+
|
|
1042
|
+
### 5. Performance
|
|
1043
|
+
|
|
1044
|
+
**Optimize for performance:**
|
|
1045
|
+
```typescript
|
|
1046
|
+
// ✅ Memoize expensive computations
|
|
1047
|
+
const processedData = useMemo(() => {
|
|
1048
|
+
return expensiveCalculation(rawData);
|
|
1049
|
+
}, [rawData]);
|
|
1050
|
+
|
|
1051
|
+
// ✅ Debounce API calls
|
|
1052
|
+
const debouncedSearch = useMemo(
|
|
1053
|
+
() => debounce((query) => searchAPI(query), 500),
|
|
1054
|
+
[]
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
// ✅ Cancel pending requests on unmount
|
|
1058
|
+
useEffect(() => {
|
|
1059
|
+
const controller = new AbortController();
|
|
1060
|
+
|
|
1061
|
+
fetch('https://api.example.com/data', {
|
|
1062
|
+
signal: controller.signal
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
return () => controller.abort();
|
|
1066
|
+
}, []);
|
|
1067
|
+
|
|
1068
|
+
// ✅ Lazy load heavy components
|
|
1069
|
+
const HeavyComponent = lazy(() => import('./HeavyComponent'));
|
|
1070
|
+
|
|
1071
|
+
// ✅ Use React.memo for expensive renders
|
|
1072
|
+
export const ExpensiveComponent = React.memo(({ data }) => {
|
|
1073
|
+
// Complex rendering logic
|
|
1074
|
+
});
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
---
|
|
1078
|
+
|
|
1079
|
+
### 6. Track Everything
|
|
1080
|
+
|
|
1081
|
+
**Comprehensive analytics tracking:**
|
|
1082
|
+
```typescript
|
|
1083
|
+
// Screen lifecycle
|
|
1084
|
+
useEffect(() => {
|
|
1085
|
+
const startTime = Date.now();
|
|
1086
|
+
|
|
1087
|
+
analytics.track('screen_viewed', {
|
|
1088
|
+
screen_id: 'meal_tracker',
|
|
1089
|
+
screen_type: 'custom',
|
|
1090
|
+
timestamp: startTime
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
return () => {
|
|
1094
|
+
analytics.track('screen_exited', {
|
|
1095
|
+
screen_id: 'meal_tracker',
|
|
1096
|
+
time_spent_ms: Date.now() - startTime
|
|
1097
|
+
});
|
|
1098
|
+
};
|
|
1099
|
+
}, []);
|
|
1100
|
+
|
|
1101
|
+
// User interactions
|
|
1102
|
+
analytics.track('camera_opened');
|
|
1103
|
+
analytics.track('photo_captured', { quality: 0.7 });
|
|
1104
|
+
analytics.track('photo_retaken');
|
|
1105
|
+
analytics.track('analysis_requested');
|
|
1106
|
+
analytics.track('analysis_completed', { calories: 450, confidence: 0.92 });
|
|
1107
|
+
analytics.track('result_viewed');
|
|
1108
|
+
analytics.track('screen_completed');
|
|
1109
|
+
analytics.track('screen_skipped', { reason: 'user_declined' });
|
|
1110
|
+
|
|
1111
|
+
// Errors
|
|
1112
|
+
analytics.track('camera_permission_denied');
|
|
1113
|
+
analytics.track('camera_error', { error: error.message });
|
|
1114
|
+
analytics.track('api_error', { endpoint: '/analyze', status: 500 });
|
|
1115
|
+
analytics.track('network_timeout');
|
|
1116
|
+
|
|
1117
|
+
// Performance
|
|
1118
|
+
analytics.track('api_latency', {
|
|
1119
|
+
endpoint: '/analyze',
|
|
1120
|
+
duration_ms: 2341,
|
|
1121
|
+
success: true
|
|
1122
|
+
});
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
---
|
|
1126
|
+
|
|
1127
|
+
## Common Patterns
|
|
1128
|
+
|
|
1129
|
+
### Pattern 1: Permission Request
|
|
1130
|
+
```typescript
|
|
1131
|
+
export const PermissionScreen = ({
|
|
1132
|
+
analytics,
|
|
1133
|
+
onNext,
|
|
1134
|
+
onSkip,
|
|
1135
|
+
preview
|
|
1136
|
+
}) => {
|
|
1137
|
+
const [permissionStatus, setPermissionStatus] = useState(null);
|
|
1138
|
+
|
|
1139
|
+
useEffect(() => {
|
|
1140
|
+
analytics.track('screen_viewed', {
|
|
1141
|
+
screen_id: 'location_permission'
|
|
1142
|
+
});
|
|
1143
|
+
}, []);
|
|
1144
|
+
|
|
1145
|
+
// Preview mode
|
|
1146
|
+
if (preview) {
|
|
1147
|
+
return (
|
|
1148
|
+
<View style={{ padding: 20, alignItems: 'center' }}>
|
|
1149
|
+
<Text style={{ fontSize: 64, marginBottom: 20 }}>📍</Text>
|
|
1150
|
+
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 12 }}>
|
|
1151
|
+
Location Access
|
|
1152
|
+
</Text>
|
|
1153
|
+
<Text style={{ fontSize: 16, color: '#666', textAlign: 'center' }}>
|
|
1154
|
+
We use your location to find nearby gyms and track outdoor workouts
|
|
1155
|
+
</Text>
|
|
1156
|
+
<Text style={{ fontSize: 14, color: '#999', marginTop: 12 }}>
|
|
1157
|
+
(Preview mode - no actual permission request)
|
|
1158
|
+
</Text>
|
|
1159
|
+
<View style={{ marginTop: 32, width: '100%', gap: 12 }}>
|
|
1160
|
+
<Button title="Allow (Mock)" onPress={onNext} />
|
|
1161
|
+
<Button title="Don't Allow (Mock)" onPress={onSkip} color="#666" />
|
|
1162
|
+
</View>
|
|
1163
|
+
</View>
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Real implementation
|
|
1168
|
+
const requestPermission = async () => {
|
|
1169
|
+
analytics.track('permission_requested', { type: 'location' });
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
1173
|
+
|
|
1174
|
+
setPermissionStatus(status);
|
|
1175
|
+
|
|
1176
|
+
if (status === 'granted') {
|
|
1177
|
+
analytics.track('permission_granted', { type: 'location' });
|
|
1178
|
+
onNext();
|
|
1179
|
+
} else {
|
|
1180
|
+
analytics.track('permission_denied', { type: 'location' });
|
|
1181
|
+
// Show explanation or allow skip
|
|
1182
|
+
}
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
analytics.track('permission_error', {
|
|
1185
|
+
type: 'location',
|
|
1186
|
+
error: error.message
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
if (permissionStatus === 'denied') {
|
|
1192
|
+
return (
|
|
1193
|
+
<View style={{ padding: 20 }}>
|
|
1194
|
+
<Text style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 12 }}>
|
|
1195
|
+
Location Access Denied
|
|
1196
|
+
</Text>
|
|
1197
|
+
<Text style={{ marginBottom: 20, color: '#666' }}>
|
|
1198
|
+
You can enable location access later in Settings to use location features.
|
|
1199
|
+
</Text>
|
|
1200
|
+
<Button title="Continue Anyway" onPress={onNext} />
|
|
1201
|
+
</View>
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return (
|
|
1206
|
+
<View style={{ padding: 20, alignItems: 'center' }}>
|
|
1207
|
+
<Text style={{ fontSize: 64, marginBottom: 20 }}>📍</Text>
|
|
1208
|
+
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 12 }}>
|
|
1209
|
+
Enable Location
|
|
1210
|
+
</Text>
|
|
1211
|
+
<Text style={{ fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 32 }}>
|
|
1212
|
+
We use your location to find nearby gyms and track outdoor workouts
|
|
1213
|
+
</Text>
|
|
1214
|
+
<Button title="Enable Location" onPress={requestPermission} />
|
|
1215
|
+
{onSkip && (
|
|
1216
|
+
<Button title="Skip for Now" onPress={onSkip} color="#666" />
|
|
1217
|
+
)}
|
|
1218
|
+
</View>
|
|
1219
|
+
);
|
|
1220
|
+
};
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
---
|
|
1224
|
+
|
|
1225
|
+
### Pattern 2: Multi-Step Process
|
|
1226
|
+
```typescript
|
|
1227
|
+
export const MultiStepScreen = ({ analytics, onNext, preview }) => {
|
|
1228
|
+
const [step, setStep] = useState(1);
|
|
1229
|
+
const [data, setData] = useState({});
|
|
1230
|
+
|
|
1231
|
+
useEffect(() => {
|
|
1232
|
+
analytics.track('screen_viewed', {
|
|
1233
|
+
screen_id: 'multi_step',
|
|
1234
|
+
initial_step: 1
|
|
1235
|
+
});
|
|
1236
|
+
}, []);
|
|
1237
|
+
|
|
1238
|
+
useEffect(() => {
|
|
1239
|
+
analytics.track('step_viewed', {
|
|
1240
|
+
screen_id: 'multi_step',
|
|
1241
|
+
step: step
|
|
1242
|
+
});
|
|
1243
|
+
}, [step]);
|
|
1244
|
+
|
|
1245
|
+
const handleStep1Complete = (stepData) => {
|
|
1246
|
+
setData({ ...data, ...stepData });
|
|
1247
|
+
analytics.track('step_completed', { step: 1 });
|
|
1248
|
+
setStep(2);
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
const handleStep2Complete = (stepData) => {
|
|
1252
|
+
setData({ ...data, ...stepData });
|
|
1253
|
+
analytics.track('step_completed', { step: 2 });
|
|
1254
|
+
setStep(3);
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
const handleFinalComplete = (stepData) => {
|
|
1258
|
+
const finalData = { ...data, ...stepData };
|
|
1259
|
+
analytics.track('screen_completed', {
|
|
1260
|
+
screen_id: 'multi_step',
|
|
1261
|
+
total_steps: 3
|
|
1262
|
+
});
|
|
1263
|
+
onNext();
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
return (
|
|
1267
|
+
<View style={{ flex: 1 }}>
|
|
1268
|
+
{/* Progress indicator */}
|
|
1269
|
+
<View style={styles.progressBar}>
|
|
1270
|
+
<View style={[styles.progress, { width: `${(step / 3) * 100}%` }]} />
|
|
1271
|
+
</View>
|
|
1272
|
+
|
|
1273
|
+
{/* Step content */}
|
|
1274
|
+
{step === 1 && <Step1 onComplete={handleStep1Complete} />}
|
|
1275
|
+
{step === 2 && <Step2 onComplete={handleStep2Complete} onBack={() => setStep(1)} />}
|
|
1276
|
+
{step === 3 && <Step3 onComplete={handleFinalComplete} onBack={() => setStep(2)} />}
|
|
1277
|
+
</View>
|
|
1278
|
+
);
|
|
1279
|
+
};
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
---
|
|
1283
|
+
|
|
1284
|
+
### Pattern 3: API Integration with Retry
|
|
1285
|
+
```typescript
|
|
1286
|
+
export const APIIntegrationScreen = ({ analytics, onNext, preview }) => {
|
|
1287
|
+
const [loading, setLoading] = useState(false);
|
|
1288
|
+
const [error, setError] = useState(null);
|
|
1289
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
1290
|
+
const maxRetries = 3;
|
|
1291
|
+
|
|
1292
|
+
const fetchData = async () => {
|
|
1293
|
+
setLoading(true);
|
|
1294
|
+
setError(null);
|
|
1295
|
+
|
|
1296
|
+
analytics.track('api_request_started', {
|
|
1297
|
+
attempt: retryCount + 1,
|
|
1298
|
+
max_attempts: maxRetries
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
try {
|
|
1302
|
+
const response = await fetch('https://your-api.com/endpoint', {
|
|
1303
|
+
method: 'POST',
|
|
1304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1305
|
+
body: JSON.stringify({ /* data */ }),
|
|
1306
|
+
timeout: 10000
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
if (!response.ok) {
|
|
1310
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const data = await response.json();
|
|
1314
|
+
|
|
1315
|
+
analytics.track('api_request_completed', {
|
|
1316
|
+
attempt: retryCount + 1,
|
|
1317
|
+
success: true
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
// Success - move to next screen
|
|
1321
|
+
onNext();
|
|
1322
|
+
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
analytics.track('api_request_failed', {
|
|
1325
|
+
attempt: retryCount + 1,
|
|
1326
|
+
error: error.message,
|
|
1327
|
+
will_retry: retryCount < maxRetries
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
if (retryCount < maxRetries) {
|
|
1331
|
+
// Retry with exponential backoff
|
|
1332
|
+
const delay = Math.pow(2, retryCount) * 1000;
|
|
1333
|
+
setTimeout(() => {
|
|
1334
|
+
setRetryCount(retryCount + 1);
|
|
1335
|
+
fetchData();
|
|
1336
|
+
}, delay);
|
|
1337
|
+
} else {
|
|
1338
|
+
// Max retries reached
|
|
1339
|
+
setError(error.message);
|
|
1340
|
+
}
|
|
1341
|
+
} finally {
|
|
1342
|
+
setLoading(false);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
if (error) {
|
|
1347
|
+
return (
|
|
1348
|
+
<View style={{ padding: 20, alignItems: 'center' }}>
|
|
1349
|
+
<Text style={{ fontSize: 64, marginBottom: 20 }}>⚠️</Text>
|
|
1350
|
+
<Text style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 12 }}>
|
|
1351
|
+
Connection Failed
|
|
1352
|
+
</Text>
|
|
1353
|
+
<Text style={{ color: '#666', marginBottom: 32, textAlign: 'center' }}>
|
|
1354
|
+
We couldn't connect to the server. Please check your internet connection and try again.
|
|
1355
|
+
</Text>
|
|
1356
|
+
<Button
|
|
1357
|
+
title="Try Again"
|
|
1358
|
+
onPress={() => {
|
|
1359
|
+
setRetryCount(0);
|
|
1360
|
+
fetchData();
|
|
1361
|
+
}}
|
|
1362
|
+
/>
|
|
1363
|
+
<Button title="Skip for Now" onPress={onSkip} color="#666" />
|
|
1364
|
+
</View>
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (loading) {
|
|
1369
|
+
return (
|
|
1370
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
1371
|
+
<ActivityIndicator size="large" />
|
|
1372
|
+
<Text style={{ marginTop: 20 }}>
|
|
1373
|
+
{retryCount > 0 ? `Retrying... (${retryCount}/${maxRetries})` : 'Connecting...'}
|
|
1374
|
+
</Text>
|
|
1375
|
+
</View>
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return (
|
|
1380
|
+
<View style={{ padding: 20 }}>
|
|
1381
|
+
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 20 }}>
|
|
1382
|
+
Sync Your Data
|
|
1383
|
+
</Text>
|
|
1384
|
+
<Button title="Connect" onPress={fetchData} />
|
|
1385
|
+
</View>
|
|
1386
|
+
);
|
|
1387
|
+
};
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
---
|
|
1391
|
+
|
|
1392
|
+
### Pattern 4: Form with Validation
|
|
1393
|
+
```typescript
|
|
1394
|
+
export const FormScreen = ({ analytics, onNext, onDataUpdate }) => {
|
|
1395
|
+
const [formData, setFormData] = useState({
|
|
1396
|
+
name: '',
|
|
1397
|
+
email: '',
|
|
1398
|
+
phone: ''
|
|
1399
|
+
});
|
|
1400
|
+
const [errors, setErrors] = useState({});
|
|
1401
|
+
|
|
1402
|
+
const validate = () => {
|
|
1403
|
+
const newErrors = {};
|
|
1404
|
+
|
|
1405
|
+
if (!formData.name.trim()) {
|
|
1406
|
+
newErrors.name = 'Name is required';
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (!formData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
|
1410
|
+
newErrors.email = 'Invalid email address';
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (!formData.phone.match(/^\d{10}$/)) {
|
|
1414
|
+
newErrors.phone = 'Phone must be 10 digits';
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
setErrors(newErrors);
|
|
1418
|
+
return Object.keys(newErrors).length === 0;
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
const handleSubmit = () => {
|
|
1422
|
+
analytics.track('form_submitted', {
|
|
1423
|
+
screen_id: 'contact_form'
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
if (validate()) {
|
|
1427
|
+
analytics.track('form_valid', {
|
|
1428
|
+
fields_filled: Object.keys(formData).length
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
onDataUpdate?.(formData);
|
|
1432
|
+
onNext();
|
|
1433
|
+
} else {
|
|
1434
|
+
analytics.track('form_invalid', {
|
|
1435
|
+
errors: Object.keys(errors)
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
return (
|
|
1441
|
+
<View style={{ padding: 20 }}>
|
|
1442
|
+
<TextInput
|
|
1443
|
+
placeholder="Name"
|
|
1444
|
+
value={formData.name}
|
|
1445
|
+
onChangeText={(name) => setFormData({ ...formData, name })}
|
|
1446
|
+
/>
|
|
1447
|
+
{errors.name && <Text style={styles.error}>{errors.name}</Text>}
|
|
1448
|
+
|
|
1449
|
+
<TextInput
|
|
1450
|
+
placeholder="Email"
|
|
1451
|
+
value={formData.email}
|
|
1452
|
+
keyboardType="email-address"
|
|
1453
|
+
onChangeText={(email) => setFormData({ ...formData, email })}
|
|
1454
|
+
/>
|
|
1455
|
+
{errors.email && <Text style={styles.error}>{errors.email}</Text>}
|
|
1456
|
+
|
|
1457
|
+
<TextInput
|
|
1458
|
+
placeholder="Phone"
|
|
1459
|
+
value={formData.phone}
|
|
1460
|
+
keyboardType="phone-pad"
|
|
1461
|
+
onChangeText={(phone) => setFormData({ ...formData, phone })}
|
|
1462
|
+
/>
|
|
1463
|
+
{errors.phone && <Text style={styles.error}>{errors.phone}</Text>}
|
|
1464
|
+
|
|
1465
|
+
<Button title="Continue" onPress={handleSubmit} />
|
|
1466
|
+
</View>
|
|
1467
|
+
);
|
|
1468
|
+
};
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
---
|
|
1472
|
+
|
|
1473
|
+
## Version Management
|
|
1474
|
+
|
|
1475
|
+
### Understanding Version Requirements
|
|
1476
|
+
|
|
1477
|
+
Custom screens exist **in your app code**. If you add a new custom screen or update an existing one, you need an App Store update.
|
|
1478
|
+
|
|
1479
|
+
**Timeline:**
|
|
1480
|
+
```
|
|
1481
|
+
Day 1: Add MealTrackerScreen to app v1.1.0, submit to App Store
|
|
1482
|
+
Day 3: App Store approves v1.1.0
|
|
1483
|
+
Day 5: 30% of users updated to v1.1.0
|
|
1484
|
+
Day 7: 60% of users updated to v1.1.0
|
|
1485
|
+
Day 14: 90% of users updated to v1.1.0
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
**Problem:** What happens if you publish the config with MealTrackerScreen on Day 4, when only 30% of users have the new app?
|
|
1489
|
+
|
|
1490
|
+
**Answer:** 70% of users will encounter an error (component not found).
|
|
1491
|
+
|
|
1492
|
+
---
|
|
1493
|
+
|
|
1494
|
+
### Solution: Minimum Version Requirements
|
|
1495
|
+
|
|
1496
|
+
Set a minimum app version in the dashboard:
|
|
1497
|
+
```json
|
|
1498
|
+
{
|
|
1499
|
+
"id": "meal_tracker",
|
|
1500
|
+
"type": "custom",
|
|
1501
|
+
"custom_component_name": "MealTrackerScreen",
|
|
1502
|
+
"min_app_version": "1.1.0"
|
|
1503
|
+
}
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
**SDK behavior:**
|
|
1507
|
+
```typescript
|
|
1508
|
+
// SDK checks version before rendering
|
|
1509
|
+
import { getVersion } from 'react-native-device-info';
|
|
1510
|
+
|
|
1511
|
+
const currentVersion = getVersion(); // e.g., "1.0.5"
|
|
1512
|
+
|
|
1513
|
+
if (screenConfig.min_app_version) {
|
|
1514
|
+
if (!meetsMinVersion(currentVersion, screenConfig.min_app_version)) {
|
|
1515
|
+
// Skip this screen for users on old app version
|
|
1516
|
+
analytics.track('screen_skipped_version_mismatch', {
|
|
1517
|
+
screen_id: screenConfig.id,
|
|
1518
|
+
current_version: currentVersion,
|
|
1519
|
+
required_version: screenConfig.min_app_version
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
// Move to next screen
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// Render screen for users on new version
|
|
1528
|
+
return <CustomComponent />;
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
---
|
|
1532
|
+
|
|
1533
|
+
### Dashboard Version Check
|
|
1534
|
+
|
|
1535
|
+
The dashboard shows version distribution:
|
|
1536
|
+
```
|
|
1537
|
+
┌────────────────────────────────────────────────┐
|
|
1538
|
+
│ App Version Distribution │
|
|
1539
|
+
├────────────────────────────────────────────────┤
|
|
1540
|
+
│ │
|
|
1541
|
+
│ v1.1.0: ████████████████████░░ 90% (9,000) │
|
|
1542
|
+
│ v1.0.5: ████░░░░░░░░░░░░░░░░░░ 8% (800) │
|
|
1543
|
+
│ v1.0.0: ░░░░░░░░░░░░░░░░░░░░░░ 2% (200) │
|
|
1544
|
+
│ │
|
|
1545
|
+
│ Custom Screen: MealTrackerScreen │
|
|
1546
|
+
│ Requires: v1.1.0+ │
|
|
1547
|
+
│ Coverage: 90% of users can see this screen │
|
|
1548
|
+
│ │
|
|
1549
|
+
│ ✅ Safe to publish (high coverage) │
|
|
1550
|
+
│ │
|
|
1551
|
+
│ [Publish Now] [Wait for 95% Coverage] │
|
|
1552
|
+
└────────────────────────────────────────────────┘
|
|
1553
|
+
```
|
|
1554
|
+
|
|
1555
|
+
---
|
|
1556
|
+
|
|
1557
|
+
### Gradual Rollout Strategy
|
|
1558
|
+
|
|
1559
|
+
**Recommended workflow:**
|
|
1560
|
+
|
|
1561
|
+
1. **Add custom screen to app** (v1.1.0)
|
|
1562
|
+
2. **Submit to App Store**
|
|
1563
|
+
3. **Wait for approval**
|
|
1564
|
+
4. **Monitor adoption** (check dashboard version analytics)
|
|
1565
|
+
5. **Wait until 90%+ users on v1.1.0**
|
|
1566
|
+
6. **Then publish config** with min_app_version set
|
|
1567
|
+
|
|
1568
|
+
**This ensures:**
|
|
1569
|
+
- Minimal user disruption
|
|
1570
|
+
- High success rate
|
|
1571
|
+
- Good user experience
|
|
1572
|
+
|
|
1573
|
+
---
|
|
1574
|
+
|
|
1575
|
+
### Handling Missing Components
|
|
1576
|
+
|
|
1577
|
+
**If user is on old app without the component:**
|
|
1578
|
+
```typescript
|
|
1579
|
+
// SDK gracefully handles missing components
|
|
1580
|
+
const CustomComponent = customComponents[screenConfig.custom_component_name];
|
|
1581
|
+
|
|
1582
|
+
if (!CustomComponent) {
|
|
1583
|
+
analytics.track('custom_component_missing', {
|
|
1584
|
+
component_name: screenConfig.custom_component_name,
|
|
1585
|
+
app_version: getVersion()
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// Show update prompt
|
|
1589
|
+
return (
|
|
1590
|
+
<View style={styles.updatePrompt}>
|
|
1591
|
+
<Text style={styles.updateTitle}>Update Required</Text>
|
|
1592
|
+
<Text style={styles.updateMessage}>
|
|
1593
|
+
This feature requires the latest version of the app.
|
|
1594
|
+
Please update to continue.
|
|
1595
|
+
</Text>
|
|
1596
|
+
<Button
|
|
1597
|
+
title="Update Now"
|
|
1598
|
+
onPress={() => Linking.openURL('app-store-link')}
|
|
1599
|
+
/>
|
|
1600
|
+
<Button
|
|
1601
|
+
title="Skip for Now"
|
|
1602
|
+
onPress={onSkip}
|
|
1603
|
+
color="#666"
|
|
1604
|
+
/>
|
|
1605
|
+
</View>
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return <CustomComponent {...props} />;
|
|
1610
|
+
```
|
|
1611
|
+
|
|
1612
|
+
---
|
|
1613
|
+
|
|
1614
|
+
## Troubleshooting
|
|
1615
|
+
|
|
1616
|
+
### Component Not Rendering
|
|
1617
|
+
|
|
1618
|
+
**Problem:** Custom screen doesn't appear in the app.
|
|
1619
|
+
|
|
1620
|
+
**Checklist:**
|
|
1621
|
+
|
|
1622
|
+
1. **Is the component registered?**
|
|
1623
|
+
```typescript
|
|
1624
|
+
<OnboardingFlow
|
|
1625
|
+
customComponents={{
|
|
1626
|
+
MealTrackerScreen: MealTrackerScreen // ← Must be here
|
|
1627
|
+
}}
|
|
1628
|
+
/>
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
2. **Does the name match exactly?**
|
|
1632
|
+
```typescript
|
|
1633
|
+
// Dashboard config
|
|
1634
|
+
"custom_component_name": "MealTrackerScreen"
|
|
1635
|
+
|
|
1636
|
+
// Component registration
|
|
1637
|
+
customComponents={{
|
|
1638
|
+
MealTrackerScreen: MealTrackerScreen // ← Must match exactly (case-sensitive)
|
|
1639
|
+
}}
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
3. **Is the component exported?**
|
|
1643
|
+
```typescript
|
|
1644
|
+
// ✅ Correct
|
|
1645
|
+
export const MealTrackerScreen = ({ ... }) => { ... };
|
|
1646
|
+
|
|
1647
|
+
// ❌ Wrong (default export)
|
|
1648
|
+
export default MealTrackerScreen;
|
|
1649
|
+
|
|
1650
|
+
// ❌ Wrong (not exported)
|
|
1651
|
+
const MealTrackerScreen = ({ ... }) => { ... };
|
|
1652
|
+
```
|
|
1653
|
+
|
|
1654
|
+
4. **Is the app version sufficient?**
|
|
1655
|
+
- Check if `min_app_version` is set
|
|
1656
|
+
- Verify user's app meets minimum version
|
|
1657
|
+
|
|
1658
|
+
5. **Check console for errors:**
|
|
1659
|
+
```
|
|
1660
|
+
- "CustomComponent not found: MealTrackerScreen"
|
|
1661
|
+
- Import errors
|
|
1662
|
+
- Syntax errors in component
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
---
|
|
1666
|
+
|
|
1667
|
+
### Preview Not Showing
|
|
1668
|
+
|
|
1669
|
+
**Problem:** Preview shows blank/error in dashboard.
|
|
1670
|
+
|
|
1671
|
+
**Solutions:**
|
|
1672
|
+
|
|
1673
|
+
1. **Implement preview mode:**
|
|
1674
|
+
```typescript
|
|
1675
|
+
if (preview) {
|
|
1676
|
+
return <PreviewPlaceholder />;
|
|
1677
|
+
}
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
2. **Check for native API calls:**
|
|
1681
|
+
- Camera, Location, Biometrics won't work in browser
|
|
1682
|
+
- Always check `preview` prop before using native features
|
|
1683
|
+
|
|
1684
|
+
3. **Test in browser console:**
|
|
1685
|
+
- Open browser dev tools
|
|
1686
|
+
- Look for errors
|
|
1687
|
+
- Check network requests
|
|
1688
|
+
|
|
1689
|
+
---
|
|
1690
|
+
|
|
1691
|
+
### Analytics Not Tracking
|
|
1692
|
+
|
|
1693
|
+
**Problem:** Events from custom screen not appearing in dashboard.
|
|
1694
|
+
|
|
1695
|
+
**Solutions:**
|
|
1696
|
+
|
|
1697
|
+
1. **Verify analytics calls:**
|
|
1698
|
+
```typescript
|
|
1699
|
+
// ✅ Correct
|
|
1700
|
+
analytics.track('screen_viewed', { ... });
|
|
1701
|
+
|
|
1702
|
+
// ❌ Wrong
|
|
1703
|
+
Analytics.track('screen_viewed', { ... }); // Wrong import
|
|
1704
|
+
this.analytics.track('screen_viewed', { ... }); // Wrong usage
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
2. **Check prop is passed:**
|
|
1708
|
+
```typescript
|
|
1709
|
+
export const MyScreen = ({ analytics }) => {
|
|
1710
|
+
console.log('Analytics:', analytics); // Should not be undefined
|
|
1711
|
+
```
|
|
1712
|
+
|
|
1713
|
+
3. **Track on mount:**
|
|
1714
|
+
```typescript
|
|
1715
|
+
useEffect(() => {
|
|
1716
|
+
analytics.track('screen_viewed', { ... });
|
|
1717
|
+
}, []); // Empty dependency array
|
|
1718
|
+
```
|
|
1719
|
+
|
|
1720
|
+
---
|
|
1721
|
+
|
|
1722
|
+
### onNext Not Working
|
|
1723
|
+
|
|
1724
|
+
**Problem:** Clicking continue button doesn't navigate.
|
|
1725
|
+
|
|
1726
|
+
**Solutions:**
|
|
1727
|
+
|
|
1728
|
+
1. **Verify onNext is called:**
|
|
1729
|
+
```typescript
|
|
1730
|
+
const handleContinue = () => {
|
|
1731
|
+
console.log('Continue clicked'); // Debug log
|
|
1732
|
+
analytics.track('screen_completed');
|
|
1733
|
+
onNext(); // ← Make sure this is called
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
<Button title="Continue" onPress={handleContinue} />
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
2. **Check for async issues:**
|
|
1740
|
+
```typescript
|
|
1741
|
+
// ❌ Wrong (onNext not awaited)
|
|
1742
|
+
const handleContinue = async () => {
|
|
1743
|
+
await saveData();
|
|
1744
|
+
onNext(); // Might not execute if error above
|
|
1745
|
+
};
|
|
1746
|
+
|
|
1747
|
+
// ✅ Correct (use try/finally)
|
|
1748
|
+
const handleContinue = async () => {
|
|
1749
|
+
try {
|
|
1750
|
+
await saveData();
|
|
1751
|
+
} finally {
|
|
1752
|
+
onNext(); // Always executes
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
---
|
|
1758
|
+
|
|
1759
|
+
## Advanced Topics
|
|
1760
|
+
|
|
1761
|
+
### Conditional Display
|
|
1762
|
+
|
|
1763
|
+
Use platform config to show/hide custom screens based on user data:
|
|
1764
|
+
```json
|
|
1765
|
+
{
|
|
1766
|
+
"screens": [
|
|
1767
|
+
{
|
|
1768
|
+
"id": "welcome",
|
|
1769
|
+
"type": "welcome_screen"
|
|
1770
|
+
},
|
|
1771
|
+
{
|
|
1772
|
+
"id": "meal_tracker",
|
|
1773
|
+
"type": "custom",
|
|
1774
|
+
"custom_component_name": "MealTrackerScreen",
|
|
1775
|
+
"conditions": {
|
|
1776
|
+
"show_if": {
|
|
1777
|
+
"all": [
|
|
1778
|
+
{"variable": "interested_in_nutrition", "equals": true},
|
|
1779
|
+
{"variable": "has_camera_permission", "equals": true}
|
|
1780
|
+
]
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
]
|
|
1785
|
+
}
|
|
1786
|
+
```
|
|
1787
|
+
|
|
1788
|
+
---
|
|
1789
|
+
|
|
1790
|
+
### Data Persistence
|
|
1791
|
+
|
|
1792
|
+
Custom screens can use AsyncStorage for local persistence:
|
|
1793
|
+
```typescript
|
|
1794
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
1795
|
+
|
|
1796
|
+
export const DataCollectionScreen = ({ analytics, onNext, onDataUpdate }) => {
|
|
1797
|
+
const [data, setData] = useState({});
|
|
1798
|
+
|
|
1799
|
+
// Load persisted data on mount
|
|
1800
|
+
useEffect(() => {
|
|
1801
|
+
const loadData = async () => {
|
|
1802
|
+
const saved = await AsyncStorage.getItem('custom_screen_data');
|
|
1803
|
+
if (saved) {
|
|
1804
|
+
setData(JSON.parse(saved));
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
loadData();
|
|
1808
|
+
}, []);
|
|
1809
|
+
|
|
1810
|
+
// Save data on change
|
|
1811
|
+
const updateData = async (newData) => {
|
|
1812
|
+
setData(newData);
|
|
1813
|
+
await AsyncStorage.setItem('custom_screen_data', JSON.stringify(newData));
|
|
1814
|
+
onDataUpdate?.(newData);
|
|
1815
|
+
};
|
|
1816
|
+
|
|
1817
|
+
// Clear on complete
|
|
1818
|
+
const handleComplete = async () => {
|
|
1819
|
+
await AsyncStorage.removeItem('custom_screen_data');
|
|
1820
|
+
onNext();
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
return (
|
|
1824
|
+
<View>
|
|
1825
|
+
{/* Form fields */}
|
|
1826
|
+
<Button title="Continue" onPress={handleComplete} />
|
|
1827
|
+
</View>
|
|
1828
|
+
);
|
|
1829
|
+
};
|
|
1830
|
+
```
|
|
1831
|
+
|
|
1832
|
+
---
|
|
1833
|
+
|
|
1834
|
+
### Deep Linking
|
|
1835
|
+
|
|
1836
|
+
Handle deep links within custom screens:
|
|
1837
|
+
```typescript
|
|
1838
|
+
import { Linking } from 'react-native';
|
|
1839
|
+
|
|
1840
|
+
export const AuthScreen = ({ analytics, onNext }) => {
|
|
1841
|
+
useEffect(() => {
|
|
1842
|
+
const handleDeepLink = (event) => {
|
|
1843
|
+
const { url } = event;
|
|
1844
|
+
|
|
1845
|
+
// Parse auth callback
|
|
1846
|
+
if (url.includes('/auth/callback')) {
|
|
1847
|
+
const token = extractTokenFromUrl(url);
|
|
1848
|
+
|
|
1849
|
+
analytics.track('auth_completed', {
|
|
1850
|
+
method: 'oauth',
|
|
1851
|
+
provider: 'google'
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
// Save token and continue
|
|
1855
|
+
saveAuthToken(token);
|
|
1856
|
+
onNext();
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
|
|
1860
|
+
Linking.addEventListener('url', handleDeepLink);
|
|
1861
|
+
|
|
1862
|
+
return () => {
|
|
1863
|
+
Linking.removeEventListener('url', handleDeepLink);
|
|
1864
|
+
};
|
|
1865
|
+
}, []);
|
|
1866
|
+
|
|
1867
|
+
const initiateOAuth = async () => {
|
|
1868
|
+
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?...';
|
|
1869
|
+
await Linking.openURL(authUrl);
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
return (
|
|
1873
|
+
<View>
|
|
1874
|
+
<Button title="Sign in with Google" onPress={initiateOAuth} />
|
|
1875
|
+
</View>
|
|
1876
|
+
);
|
|
1877
|
+
};
|
|
1878
|
+
```
|
|
1879
|
+
|
|
1880
|
+
---
|
|
1881
|
+
|
|
1882
|
+
### Using Context
|
|
1883
|
+
|
|
1884
|
+
Share data across multiple custom screens:
|
|
1885
|
+
```typescript
|
|
1886
|
+
// OnboardingContext.tsx
|
|
1887
|
+
import React, { createContext, useContext, useState } from 'react';
|
|
1888
|
+
|
|
1889
|
+
const OnboardingContext = createContext(null);
|
|
1890
|
+
|
|
1891
|
+
export const OnboardingProvider = ({ children }) => {
|
|
1892
|
+
const [sharedData, setSharedData] = useState({});
|
|
1893
|
+
|
|
1894
|
+
return (
|
|
1895
|
+
<OnboardingContext.Provider value={{ sharedData, setSharedData }}>
|
|
1896
|
+
{children}
|
|
1897
|
+
</OnboardingContext.Provider>
|
|
1898
|
+
);
|
|
1899
|
+
};
|
|
1900
|
+
|
|
1901
|
+
export const useOnboarding = () => useContext(OnboardingContext);
|
|
1902
|
+
|
|
1903
|
+
// App.tsx
|
|
1904
|
+
<OnboardingProvider>
|
|
1905
|
+
<OnboardingFlow
|
|
1906
|
+
customComponents={{ Screen1, Screen2 }}
|
|
1907
|
+
/>
|
|
1908
|
+
</OnboardingProvider>
|
|
1909
|
+
|
|
1910
|
+
// Screen1.tsx
|
|
1911
|
+
export const Screen1 = ({ onNext }) => {
|
|
1912
|
+
const { sharedData, setSharedData } = useOnboarding();
|
|
1913
|
+
|
|
1914
|
+
const handleContinue = () => {
|
|
1915
|
+
setSharedData({ ...sharedData, step1Complete: true });
|
|
1916
|
+
onNext();
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
return <View>...</View>;
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
// Screen2.tsx
|
|
1923
|
+
export const Screen2 = ({ onNext }) => {
|
|
1924
|
+
const { sharedData } = useOnboarding();
|
|
1925
|
+
|
|
1926
|
+
// Access data from Screen1
|
|
1927
|
+
console.log(sharedData.step1Complete); // true
|
|
1928
|
+
|
|
1929
|
+
return <View>...</View>;
|
|
1930
|
+
};
|
|
1931
|
+
```
|
|
1932
|
+
|
|
1933
|
+
---
|
|
1934
|
+
|
|
1935
|
+
## Summary
|
|
1936
|
+
|
|
1937
|
+
Custom screens give you the flexibility to build complex, native-integrated onboarding experiences while still benefiting from our platform's analytics, A/B testing, and flow management.
|
|
1938
|
+
|
|
1939
|
+
**Key takeaways:**
|
|
1940
|
+
|
|
1941
|
+
1. ✅ Custom screens live in YOUR app code
|
|
1942
|
+
2. ✅ Changes require App Store approval
|
|
1943
|
+
3. ✅ Always implement preview mode for dashboard
|
|
1944
|
+
4. ✅ Track events comprehensively with analytics
|
|
1945
|
+
5. ✅ Handle errors gracefully
|
|
1946
|
+
6. ✅ Use min_app_version for gradual rollout
|
|
1947
|
+
7. ✅ Custom screens don't count toward MAU limits
|
|
1948
|
+
|
|
1949
|
+
**Next steps:**
|
|
1950
|
+
|
|
1951
|
+
- Create your first custom screen using the Quick Start guide
|
|
1952
|
+
- Test locally with preview mode
|
|
1953
|
+
- Deploy to App Store
|
|
1954
|
+
- Monitor analytics in dashboard
|
|
1955
|
+
- Iterate based on user behavior
|
|
1956
|
+
|
|
1957
|
+
**Need help?**
|
|
1958
|
+
- 📖 Full documentation: https://docs.yourplatform.com
|
|
1959
|
+
- 💬 Discord community: https://discord.gg/yourplatform
|
|
1960
|
+
- ✉️ Support: support@yourplatform.com
|
|
1961
|
+
|
|
1962
|
+
---
|
|
1963
|
+
|
|
1964
|
+
**Happy building! 🚀**
|