@umituz/react-native-onboarding 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/package.json +8 -3
- package/src/domain/entities/OnboardingOptions.ts +12 -0
- package/src/domain/entities/OnboardingSlide.ts +10 -2
- package/src/domain/entities/OnboardingUserData.ts +12 -0
- package/src/index.ts +1 -0
- package/src/infrastructure/hooks/useOnboardingAnswers.ts +69 -0
- package/src/infrastructure/services/OnboardingDeviceTrackingService.ts +60 -0
- package/src/infrastructure/services/OnboardingSlideService.ts +49 -0
- package/src/infrastructure/services/OnboardingValidationService.ts +128 -0
- package/src/infrastructure/storage/OnboardingStore.ts +17 -2
- package/src/infrastructure/utils/gradientUtils.ts +26 -0
- package/src/presentation/components/OnboardingFooter.tsx +24 -10
- package/src/presentation/components/OnboardingHeader.tsx +17 -6
- package/src/presentation/components/OnboardingSlide.tsx +25 -9
- package/src/presentation/components/QuestionSlide.tsx +24 -8
- package/src/presentation/screens/OnboardingScreen.tsx +119 -121
package/README.md
CHANGED
|
@@ -185,6 +185,8 @@ const slides: OnboardingSlide[] = [
|
|
|
185
185
|
| `showProgressText` | `boolean` | `true` | Show progress text (1 of 5) |
|
|
186
186
|
| `storageKey` | `string` | - | Custom storage key for completion state |
|
|
187
187
|
| `autoComplete` | `boolean` | `false` | Auto-complete on last slide |
|
|
188
|
+
| `enableDeviceTracking` | `boolean` | `false` | Collect device info during onboarding |
|
|
189
|
+
| `userId` | `string` | - | User ID for device tracking (optional) |
|
|
188
190
|
|
|
189
191
|
### OnboardingSlide Interface
|
|
190
192
|
|
|
@@ -328,6 +330,69 @@ const isComplete = userData.completedAt !== undefined;
|
|
|
328
330
|
|
|
329
331
|
// Check if onboarding was skipped
|
|
330
332
|
const wasSkipped = userData.skipped === true;
|
|
333
|
+
|
|
334
|
+
// Get device info (if device tracking was enabled)
|
|
335
|
+
const deviceInfo = userData.deviceInfo;
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## 📱 Device Tracking (Optional)
|
|
339
|
+
|
|
340
|
+
Collect device information during onboarding for analytics or support purposes.
|
|
341
|
+
|
|
342
|
+
### Installation
|
|
343
|
+
|
|
344
|
+
First, install the device tracking package:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
npm install @umituz/react-native-device
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Usage
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
import { OnboardingScreen } from '@umituz/react-native-onboarding';
|
|
354
|
+
|
|
355
|
+
<OnboardingScreen
|
|
356
|
+
slides={slides}
|
|
357
|
+
enableDeviceTracking={true}
|
|
358
|
+
userId="user123" // Optional
|
|
359
|
+
onComplete={async () => {
|
|
360
|
+
const userData = onboardingStore.getUserData();
|
|
361
|
+
|
|
362
|
+
// Device info is now available
|
|
363
|
+
console.log('Device:', userData.deviceInfo?.platform);
|
|
364
|
+
console.log('OS:', userData.deviceInfo?.osVersion);
|
|
365
|
+
console.log('App:', userData.deviceInfo?.appVersion);
|
|
366
|
+
}}
|
|
367
|
+
/>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Device Info Structure
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
{
|
|
374
|
+
deviceId: string;
|
|
375
|
+
platform: 'ios' | 'android' | 'web';
|
|
376
|
+
osVersion: string;
|
|
377
|
+
appVersion: string;
|
|
378
|
+
deviceName: string;
|
|
379
|
+
manufacturer: string;
|
|
380
|
+
brand: string;
|
|
381
|
+
timestamp: number;
|
|
382
|
+
userId?: string; // If provided
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Manual Device Info Collection
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
import { OnboardingDeviceTrackingService } from '@umituz/react-native-onboarding';
|
|
390
|
+
|
|
391
|
+
// Collect device info manually
|
|
392
|
+
const deviceInfo = await OnboardingDeviceTrackingService.collectDeviceInfo('user123');
|
|
393
|
+
|
|
394
|
+
// Check if device tracking is available
|
|
395
|
+
const isAvailable = await OnboardingDeviceTrackingService.isDeviceTrackingAvailable();
|
|
331
396
|
```
|
|
332
397
|
|
|
333
398
|
## 🔄 Resetting Onboarding
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-onboarding",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Advanced onboarding flow for React Native apps with personalization questions,
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Advanced onboarding flow for React Native apps with personalization questions, theme-aware colors, animations, and customizable slides. SOLID, DRY, KISS principles applied.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"onboarding",
|
|
17
17
|
"welcome",
|
|
18
18
|
"tutorial",
|
|
19
|
-
"
|
|
19
|
+
"theme",
|
|
20
20
|
"animation",
|
|
21
21
|
"ddd",
|
|
22
22
|
"domain-driven-design",
|
|
@@ -44,6 +44,11 @@
|
|
|
44
44
|
"react-native-safe-area-context": "^5.0.0",
|
|
45
45
|
"zustand": "^5.0.0"
|
|
46
46
|
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@umituz/react-native-device": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
47
52
|
"devDependencies": {
|
|
48
53
|
"@types/react": "^18.2.45",
|
|
49
54
|
"@types/react-native": "^0.73.0",
|
|
@@ -85,5 +85,17 @@ export interface OnboardingOptions {
|
|
|
85
85
|
* Show paywall modal on onboarding completion (default: false)
|
|
86
86
|
*/
|
|
87
87
|
showPaywallOnComplete?: boolean;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Enable device tracking (default: false)
|
|
91
|
+
* When enabled, collects device information during onboarding
|
|
92
|
+
*/
|
|
93
|
+
enableDeviceTracking?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* User ID for device tracking (optional)
|
|
97
|
+
* Only used when enableDeviceTracking is true
|
|
98
|
+
*/
|
|
99
|
+
userId?: string;
|
|
88
100
|
}
|
|
89
101
|
|
|
@@ -42,10 +42,18 @@ export interface OnboardingSlide {
|
|
|
42
42
|
icon: string;
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Gradient colors for the slide background
|
|
45
|
+
* Gradient colors for the slide background (optional)
|
|
46
46
|
* [startColor, endColor] or [color1, color2, color3] for multi-stop gradients
|
|
47
|
+
* Only used if useGradient is true
|
|
47
48
|
*/
|
|
48
|
-
gradient
|
|
49
|
+
gradient?: string[];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Use gradient background instead of theme colors (default: false)
|
|
53
|
+
* If true and gradient is provided, gradient will be used
|
|
54
|
+
* If false or gradient not provided, theme background colors will be used
|
|
55
|
+
*/
|
|
56
|
+
useGradient?: boolean;
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
59
|
* Optional image URL (alternative to icon)
|
|
@@ -39,5 +39,17 @@ export interface OnboardingUserData {
|
|
|
39
39
|
gender?: string;
|
|
40
40
|
[key: string]: any;
|
|
41
41
|
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Device information (optional)
|
|
45
|
+
* Only collected when enableDeviceTracking is true
|
|
46
|
+
*/
|
|
47
|
+
deviceInfo?: {
|
|
48
|
+
deviceId?: string;
|
|
49
|
+
platform?: string;
|
|
50
|
+
osVersion?: string;
|
|
51
|
+
appVersion?: string;
|
|
52
|
+
[key: string]: any;
|
|
53
|
+
};
|
|
42
54
|
}
|
|
43
55
|
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export {
|
|
|
52
52
|
useOnboardingNavigation,
|
|
53
53
|
type UseOnboardingNavigationReturn,
|
|
54
54
|
} from "./infrastructure/hooks/useOnboardingNavigation";
|
|
55
|
+
export { OnboardingDeviceTrackingService } from "./infrastructure/services/OnboardingDeviceTrackingService";
|
|
55
56
|
|
|
56
57
|
// =============================================================================
|
|
57
58
|
// PRESENTATION LAYER - Components and Screens
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOnboardingAnswers Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages answer state and operations for onboarding questions
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback } from "react";
|
|
9
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
10
|
+
import { useOnboardingStore } from "../storage/OnboardingStore";
|
|
11
|
+
|
|
12
|
+
export interface UseOnboardingAnswersReturn {
|
|
13
|
+
currentAnswer: any;
|
|
14
|
+
setCurrentAnswer: (answer: any) => void;
|
|
15
|
+
loadAnswerForSlide: (slide: OnboardingSlide | undefined) => void;
|
|
16
|
+
saveCurrentAnswer: (slide: OnboardingSlide | undefined) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook for managing onboarding question answers
|
|
21
|
+
* @param currentSlide - The current slide being displayed
|
|
22
|
+
* @returns Answer state and operations
|
|
23
|
+
*/
|
|
24
|
+
export function useOnboardingAnswers(
|
|
25
|
+
currentSlide: OnboardingSlide | undefined,
|
|
26
|
+
): UseOnboardingAnswersReturn {
|
|
27
|
+
const onboardingStore = useOnboardingStore();
|
|
28
|
+
const [currentAnswer, setCurrentAnswer] = useState<any>(undefined);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load answer for a specific slide
|
|
32
|
+
*/
|
|
33
|
+
const loadAnswerForSlide = useCallback(
|
|
34
|
+
(slide: OnboardingSlide | undefined) => {
|
|
35
|
+
if (slide?.question) {
|
|
36
|
+
const savedAnswer = onboardingStore.getAnswer(slide.question.id);
|
|
37
|
+
setCurrentAnswer(savedAnswer ?? slide.question.defaultValue);
|
|
38
|
+
} else {
|
|
39
|
+
setCurrentAnswer(undefined);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[onboardingStore],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Save current answer for a slide
|
|
47
|
+
*/
|
|
48
|
+
const saveCurrentAnswer = useCallback(
|
|
49
|
+
async (slide: OnboardingSlide | undefined) => {
|
|
50
|
+
if (slide?.question && currentAnswer !== undefined) {
|
|
51
|
+
await onboardingStore.saveAnswer(slide.question.id, currentAnswer);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[currentAnswer, onboardingStore],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Load answer when slide changes
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
loadAnswerForSlide(currentSlide);
|
|
60
|
+
}, [currentSlide, loadAnswerForSlide]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
currentAnswer,
|
|
64
|
+
setCurrentAnswer,
|
|
65
|
+
loadAnswerForSlide,
|
|
66
|
+
saveCurrentAnswer,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Device Tracking Service
|
|
3
|
+
*
|
|
4
|
+
* Handles device information collection during onboarding
|
|
5
|
+
* Single Responsibility: Device tracking only
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Service for device tracking during onboarding
|
|
12
|
+
* Optional feature - only used when enableDeviceTracking is true
|
|
13
|
+
*/
|
|
14
|
+
export class OnboardingDeviceTrackingService {
|
|
15
|
+
/**
|
|
16
|
+
* Collect device information
|
|
17
|
+
* @param userId - Optional user ID
|
|
18
|
+
* @returns Device information object
|
|
19
|
+
*/
|
|
20
|
+
static async collectDeviceInfo(userId?: string): Promise<OnboardingUserData['deviceInfo']> {
|
|
21
|
+
try {
|
|
22
|
+
// Dynamic import to avoid loading @umituz/react-native-device if not needed
|
|
23
|
+
// @ts-expect-error - Optional peer dependency, may not be installed
|
|
24
|
+
const { DeviceService } = await import('@umituz/react-native-device');
|
|
25
|
+
|
|
26
|
+
const systemInfo = await DeviceService.getSystemInfo({ userId });
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
deviceId: systemInfo.device.modelId || 'unknown',
|
|
30
|
+
platform: systemInfo.device.platform,
|
|
31
|
+
osVersion: systemInfo.device.osVersion || 'unknown',
|
|
32
|
+
appVersion: systemInfo.application.nativeApplicationVersion || 'unknown',
|
|
33
|
+
deviceName: systemInfo.device.deviceName || 'unknown',
|
|
34
|
+
manufacturer: systemInfo.device.manufacturer || 'unknown',
|
|
35
|
+
brand: systemInfo.device.brand || 'unknown',
|
|
36
|
+
timestamp: systemInfo.timestamp,
|
|
37
|
+
...(userId && { userId }),
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
/* eslint-disable-next-line no-console */
|
|
41
|
+
if (__DEV__) console.warn('Failed to collect device info:', error);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if device tracking is available
|
|
48
|
+
* @returns True if @umituz/react-native-device is available
|
|
49
|
+
*/
|
|
50
|
+
static async isDeviceTrackingAvailable(): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
// @ts-expect-error - Optional peer dependency, may not be installed
|
|
53
|
+
await import('@umituz/react-native-device');
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Slide Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for filtering and processing onboarding slides
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
9
|
+
import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Service for managing onboarding slide operations
|
|
13
|
+
*/
|
|
14
|
+
export class OnboardingSlideService {
|
|
15
|
+
/**
|
|
16
|
+
* Filter slides based on skipIf conditions
|
|
17
|
+
* @param slides - All available slides
|
|
18
|
+
* @param userData - User's onboarding data including answers
|
|
19
|
+
* @returns Filtered slides that should be shown
|
|
20
|
+
*/
|
|
21
|
+
static filterSlides(
|
|
22
|
+
slides: OnboardingSlide[],
|
|
23
|
+
userData: OnboardingUserData,
|
|
24
|
+
): OnboardingSlide[] {
|
|
25
|
+
return slides.filter((slide) => {
|
|
26
|
+
if (slide.skipIf) {
|
|
27
|
+
return !slide.skipIf(userData.answers);
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get slide at specific index
|
|
35
|
+
* @param slides - Filtered slides array
|
|
36
|
+
* @param index - Slide index
|
|
37
|
+
* @returns Slide at index or undefined
|
|
38
|
+
*/
|
|
39
|
+
static getSlideAtIndex(
|
|
40
|
+
slides: OnboardingSlide[],
|
|
41
|
+
index: number,
|
|
42
|
+
): OnboardingSlide | undefined {
|
|
43
|
+
if (index < 0 || index >= slides.length) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return slides[index];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Validation Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for validating onboarding question answers
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingQuestion } from "../../domain/entities/OnboardingQuestion";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Service for validating onboarding question answers
|
|
12
|
+
*/
|
|
13
|
+
export class OnboardingValidationService {
|
|
14
|
+
/**
|
|
15
|
+
* Validate answer against question validation rules
|
|
16
|
+
* @param question - The question to validate against
|
|
17
|
+
* @param answer - The answer to validate
|
|
18
|
+
* @returns true if valid, false otherwise
|
|
19
|
+
*/
|
|
20
|
+
static validateAnswer(
|
|
21
|
+
question: OnboardingQuestion,
|
|
22
|
+
answer: any,
|
|
23
|
+
): boolean {
|
|
24
|
+
const { validation } = question;
|
|
25
|
+
if (!validation) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Required validation
|
|
30
|
+
if (validation.required && !answer) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Type-specific validations
|
|
35
|
+
switch (question.type) {
|
|
36
|
+
case "multiple_choice":
|
|
37
|
+
return this.validateMultipleChoice(answer, validation);
|
|
38
|
+
case "text_input":
|
|
39
|
+
return this.validateTextInput(answer, validation);
|
|
40
|
+
case "slider":
|
|
41
|
+
case "rating":
|
|
42
|
+
return this.validateNumeric(answer, validation);
|
|
43
|
+
default:
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Custom validator
|
|
48
|
+
if (validation.customValidator) {
|
|
49
|
+
const customResult = validation.customValidator(answer);
|
|
50
|
+
return customResult === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate multiple choice answer
|
|
58
|
+
*/
|
|
59
|
+
private static validateMultipleChoice(
|
|
60
|
+
answer: any,
|
|
61
|
+
validation: OnboardingQuestion["validation"],
|
|
62
|
+
): boolean {
|
|
63
|
+
if (!validation) return true;
|
|
64
|
+
|
|
65
|
+
if (validation.minSelections) {
|
|
66
|
+
if (!answer || !Array.isArray(answer) || answer.length < validation.minSelections) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (validation.maxSelections) {
|
|
72
|
+
if (Array.isArray(answer) && answer.length > validation.maxSelections) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate text input answer
|
|
82
|
+
*/
|
|
83
|
+
private static validateTextInput(
|
|
84
|
+
answer: any,
|
|
85
|
+
validation: OnboardingQuestion["validation"],
|
|
86
|
+
): boolean {
|
|
87
|
+
if (!validation) return true;
|
|
88
|
+
|
|
89
|
+
if (typeof answer !== "string") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (validation.minLength && answer.length < validation.minLength) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (validation.maxLength && answer.length > validation.maxLength) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate numeric answer (slider/rating)
|
|
106
|
+
*/
|
|
107
|
+
private static validateNumeric(
|
|
108
|
+
answer: any,
|
|
109
|
+
validation: OnboardingQuestion["validation"],
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!validation) return true;
|
|
112
|
+
|
|
113
|
+
if (typeof answer !== "number") {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (validation.min !== undefined && answer < validation.min) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (validation.max !== undefined && answer > validation.max) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
unwrap,
|
|
13
13
|
} from "@umituz/react-native-storage";
|
|
14
14
|
import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
|
|
15
|
+
import { OnboardingDeviceTrackingService } from "../services/OnboardingDeviceTrackingService";
|
|
15
16
|
|
|
16
17
|
interface OnboardingStore {
|
|
17
18
|
// State
|
|
@@ -23,7 +24,7 @@ interface OnboardingStore {
|
|
|
23
24
|
|
|
24
25
|
// Actions
|
|
25
26
|
initialize: (storageKey?: string) => Promise<void>;
|
|
26
|
-
complete: (storageKey?: string) => Promise<void>;
|
|
27
|
+
complete: (storageKey?: string, options?: { enableDeviceTracking?: boolean; userId?: string }) => Promise<void>;
|
|
27
28
|
skip: (storageKey?: string) => Promise<void>;
|
|
28
29
|
setCurrentStep: (step: number) => void;
|
|
29
30
|
reset: (storageKey?: string) => Promise<void>;
|
|
@@ -33,6 +34,7 @@ interface OnboardingStore {
|
|
|
33
34
|
getAnswer: (questionId: string) => any;
|
|
34
35
|
getUserData: () => OnboardingUserData;
|
|
35
36
|
setUserData: (data: OnboardingUserData) => Promise<void>;
|
|
37
|
+
collectDeviceInfo: (userId?: string) => Promise<void>;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const DEFAULT_STORAGE_KEY = StorageKey.ONBOARDING_COMPLETED;
|
|
@@ -67,7 +69,7 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
|
|
|
67
69
|
});
|
|
68
70
|
},
|
|
69
71
|
|
|
70
|
-
complete: async (storageKey = DEFAULT_STORAGE_KEY) => {
|
|
72
|
+
complete: async (storageKey = DEFAULT_STORAGE_KEY, options?: { enableDeviceTracking?: boolean; userId?: string }) => {
|
|
71
73
|
set({ loading: true, error: null });
|
|
72
74
|
|
|
73
75
|
const result = await storageRepository.setString(storageKey, "true");
|
|
@@ -75,6 +77,12 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
|
|
|
75
77
|
// Update user data with completion timestamp
|
|
76
78
|
const userData = get().userData;
|
|
77
79
|
userData.completedAt = new Date().toISOString();
|
|
80
|
+
|
|
81
|
+
// Collect device info if enabled
|
|
82
|
+
if (options?.enableDeviceTracking) {
|
|
83
|
+
userData.deviceInfo = await OnboardingDeviceTrackingService.collectDeviceInfo(options.userId);
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
|
|
79
87
|
|
|
80
88
|
set({
|
|
@@ -144,6 +152,13 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
|
|
|
144
152
|
await storageRepository.setObject(USER_DATA_STORAGE_KEY, data);
|
|
145
153
|
set({ userData: data });
|
|
146
154
|
},
|
|
155
|
+
|
|
156
|
+
collectDeviceInfo: async (userId?: string) => {
|
|
157
|
+
const userData = get().userData;
|
|
158
|
+
userData.deviceInfo = await OnboardingDeviceTrackingService.collectDeviceInfo(userId);
|
|
159
|
+
await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
|
|
160
|
+
set({ userData: { ...userData } });
|
|
161
|
+
},
|
|
147
162
|
}));
|
|
148
163
|
|
|
149
164
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gradient Utilities
|
|
3
|
+
*
|
|
4
|
+
* Utility functions for gradient-related operations
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if slide should use gradient background
|
|
12
|
+
* @param slide - The slide to check
|
|
13
|
+
* @returns true if gradient should be used, false otherwise
|
|
14
|
+
*/
|
|
15
|
+
export function shouldUseGradient(slide: OnboardingSlide | undefined): boolean {
|
|
16
|
+
if (!slide) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
slide.useGradient === true &&
|
|
22
|
+
slide.gradient !== undefined &&
|
|
23
|
+
slide.gradient.length > 0
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -21,6 +21,7 @@ export interface OnboardingFooterProps {
|
|
|
21
21
|
nextButtonText?: string;
|
|
22
22
|
getStartedButtonText?: string;
|
|
23
23
|
disabled?: boolean;
|
|
24
|
+
useGradient?: boolean;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export const OnboardingFooter: React.FC<OnboardingFooterProps> = ({
|
|
@@ -34,11 +35,12 @@ export const OnboardingFooter: React.FC<OnboardingFooterProps> = ({
|
|
|
34
35
|
nextButtonText,
|
|
35
36
|
getStartedButtonText,
|
|
36
37
|
disabled = false,
|
|
38
|
+
useGradient = false,
|
|
37
39
|
}) => {
|
|
38
40
|
const insets = useSafeAreaInsets();
|
|
39
41
|
const { t } = useLocalization();
|
|
40
42
|
const tokens = useAppDesignTokens();
|
|
41
|
-
const styles = useMemo(() => getStyles(insets, tokens), [insets, tokens]);
|
|
43
|
+
const styles = useMemo(() => getStyles(insets, tokens, useGradient), [insets, tokens, useGradient]);
|
|
42
44
|
|
|
43
45
|
const buttonText = isLastSlide
|
|
44
46
|
? getStartedButtonText || t("onboarding.getStarted", "Get Started")
|
|
@@ -93,6 +95,7 @@ export const OnboardingFooter: React.FC<OnboardingFooterProps> = ({
|
|
|
93
95
|
const getStyles = (
|
|
94
96
|
insets: { bottom: number },
|
|
95
97
|
tokens: ReturnType<typeof useAppDesignTokens>,
|
|
98
|
+
useGradient: boolean,
|
|
96
99
|
) =>
|
|
97
100
|
StyleSheet.create({
|
|
98
101
|
footer: {
|
|
@@ -105,13 +108,15 @@ const getStyles = (
|
|
|
105
108
|
},
|
|
106
109
|
progressBar: {
|
|
107
110
|
height: 4,
|
|
108
|
-
backgroundColor:
|
|
111
|
+
backgroundColor: useGradient
|
|
112
|
+
? "rgba(255, 255, 255, 0.2)"
|
|
113
|
+
: tokens.colors.borderLight,
|
|
109
114
|
borderRadius: 2,
|
|
110
115
|
overflow: "hidden",
|
|
111
116
|
},
|
|
112
117
|
progressFill: {
|
|
113
118
|
height: "100%",
|
|
114
|
-
backgroundColor: "#FFFFFF",
|
|
119
|
+
backgroundColor: useGradient ? "#FFFFFF" : tokens.colors.primary,
|
|
115
120
|
borderRadius: 2,
|
|
116
121
|
},
|
|
117
122
|
dots: {
|
|
@@ -124,32 +129,41 @@ const getStyles = (
|
|
|
124
129
|
width: 6,
|
|
125
130
|
height: 6,
|
|
126
131
|
borderRadius: 3,
|
|
127
|
-
backgroundColor:
|
|
132
|
+
backgroundColor: useGradient
|
|
133
|
+
? "rgba(255, 255, 255, 0.4)"
|
|
134
|
+
: tokens.colors.borderLight,
|
|
128
135
|
},
|
|
129
136
|
dotActive: {
|
|
130
137
|
width: 8,
|
|
131
|
-
backgroundColor: "#FFFFFF",
|
|
138
|
+
backgroundColor: useGradient ? "#FFFFFF" : tokens.colors.primary,
|
|
132
139
|
},
|
|
133
140
|
button: {
|
|
134
|
-
backgroundColor: "#FFFFFF",
|
|
141
|
+
backgroundColor: useGradient ? "#FFFFFF" : tokens.colors.primary,
|
|
135
142
|
paddingVertical: 16,
|
|
136
143
|
borderRadius: 28,
|
|
137
144
|
alignItems: "center",
|
|
138
145
|
marginBottom: 12,
|
|
139
146
|
},
|
|
140
147
|
buttonDisabled: {
|
|
141
|
-
backgroundColor:
|
|
148
|
+
backgroundColor: useGradient
|
|
149
|
+
? "rgba(255, 255, 255, 0.4)"
|
|
150
|
+
: tokens.colors.borderLight,
|
|
151
|
+
opacity: 0.5,
|
|
142
152
|
},
|
|
143
153
|
buttonText: {
|
|
144
|
-
color: tokens.colors.primary,
|
|
154
|
+
color: useGradient ? tokens.colors.primary : tokens.colors.surface,
|
|
145
155
|
fontSize: 16,
|
|
146
156
|
fontWeight: "bold",
|
|
147
157
|
},
|
|
148
158
|
buttonTextDisabled: {
|
|
149
|
-
color:
|
|
159
|
+
color: useGradient
|
|
160
|
+
? "rgba(255, 255, 255, 0.6)"
|
|
161
|
+
: tokens.colors.textSecondary,
|
|
150
162
|
},
|
|
151
163
|
progressText: {
|
|
152
|
-
color:
|
|
164
|
+
color: useGradient
|
|
165
|
+
? "rgba(255, 255, 255, 0.75)"
|
|
166
|
+
: tokens.colors.textSecondary,
|
|
153
167
|
fontSize: 12,
|
|
154
168
|
textAlign: "center",
|
|
155
169
|
},
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import React, { useMemo } from "react";
|
|
8
8
|
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
|
|
9
9
|
import { useLocalization } from "@umituz/react-native-localization";
|
|
10
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system-theme";
|
|
10
11
|
|
|
11
12
|
export interface OnboardingHeaderProps {
|
|
12
13
|
isFirstSlide: boolean;
|
|
@@ -15,6 +16,7 @@ export interface OnboardingHeaderProps {
|
|
|
15
16
|
showBackButton?: boolean;
|
|
16
17
|
showSkipButton?: boolean;
|
|
17
18
|
skipButtonText?: string;
|
|
19
|
+
useGradient?: boolean;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export const OnboardingHeader: React.FC<OnboardingHeaderProps> = ({
|
|
@@ -24,9 +26,11 @@ export const OnboardingHeader: React.FC<OnboardingHeaderProps> = ({
|
|
|
24
26
|
showBackButton = true,
|
|
25
27
|
showSkipButton = true,
|
|
26
28
|
skipButtonText,
|
|
29
|
+
useGradient = false,
|
|
27
30
|
}) => {
|
|
28
31
|
const { t } = useLocalization();
|
|
29
|
-
const
|
|
32
|
+
const tokens = useAppDesignTokens();
|
|
33
|
+
const styles = useMemo(() => getStyles(tokens, useGradient), [tokens, useGradient]);
|
|
30
34
|
|
|
31
35
|
const skipText = skipButtonText || t("onboarding.skip", "Skip");
|
|
32
36
|
|
|
@@ -55,7 +59,10 @@ export const OnboardingHeader: React.FC<OnboardingHeaderProps> = ({
|
|
|
55
59
|
);
|
|
56
60
|
};
|
|
57
61
|
|
|
58
|
-
const getStyles = (
|
|
62
|
+
const getStyles = (
|
|
63
|
+
tokens: ReturnType<typeof useAppDesignTokens>,
|
|
64
|
+
useGradient: boolean,
|
|
65
|
+
) =>
|
|
59
66
|
StyleSheet.create({
|
|
60
67
|
header: {
|
|
61
68
|
flexDirection: "row",
|
|
@@ -69,22 +76,26 @@ const getStyles = () =>
|
|
|
69
76
|
width: 40,
|
|
70
77
|
height: 40,
|
|
71
78
|
borderRadius: 20,
|
|
72
|
-
backgroundColor:
|
|
79
|
+
backgroundColor: useGradient
|
|
80
|
+
? "rgba(255, 255, 255, 0.2)"
|
|
81
|
+
: tokens.colors.surface,
|
|
73
82
|
alignItems: "center",
|
|
74
83
|
justifyContent: "center",
|
|
75
84
|
borderWidth: 1,
|
|
76
|
-
borderColor:
|
|
85
|
+
borderColor: useGradient
|
|
86
|
+
? "rgba(255, 255, 255, 0.3)"
|
|
87
|
+
: tokens.colors.borderLight,
|
|
77
88
|
},
|
|
78
89
|
headerButtonDisabled: {
|
|
79
90
|
opacity: 0.3,
|
|
80
91
|
},
|
|
81
92
|
headerButtonText: {
|
|
82
|
-
color: "#FFFFFF",
|
|
93
|
+
color: useGradient ? "#FFFFFF" : tokens.colors.textPrimary,
|
|
83
94
|
fontSize: 20,
|
|
84
95
|
fontWeight: "bold",
|
|
85
96
|
},
|
|
86
97
|
skipText: {
|
|
87
|
-
color: "#FFFFFF",
|
|
98
|
+
color: useGradient ? "#FFFFFF" : tokens.colors.textPrimary,
|
|
88
99
|
fontSize: 16,
|
|
89
100
|
fontWeight: "600",
|
|
90
101
|
},
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import React, { useMemo } from "react";
|
|
8
8
|
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
|
9
9
|
import * as LucideIcons from "lucide-react-native";
|
|
10
|
+
import { useAppDesignTokens, withAlpha } from "@umituz/react-native-design-system-theme";
|
|
10
11
|
import type { OnboardingSlide as OnboardingSlideType } from "../../domain/entities/OnboardingSlide";
|
|
11
12
|
|
|
12
13
|
export interface OnboardingSlideProps {
|
|
@@ -14,7 +15,8 @@ export interface OnboardingSlideProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export const OnboardingSlide: React.FC<OnboardingSlideProps> = ({ slide }) => {
|
|
17
|
-
const
|
|
18
|
+
const tokens = useAppDesignTokens();
|
|
19
|
+
const styles = useMemo(() => getStyles(tokens), [tokens]);
|
|
18
20
|
|
|
19
21
|
// Check if icon is an emoji (contains emoji characters) or Lucide icon name
|
|
20
22
|
const isEmoji = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(slide.icon);
|
|
@@ -30,7 +32,7 @@ export const OnboardingSlide: React.FC<OnboardingSlideProps> = ({ slide }) => {
|
|
|
30
32
|
{isEmoji ? (
|
|
31
33
|
<Text style={styles.icon}>{slide.icon}</Text>
|
|
32
34
|
) : IconComponent ? (
|
|
33
|
-
<IconComponent size={60} color=
|
|
35
|
+
<IconComponent size={60} color={tokens.colors.textPrimary} />
|
|
34
36
|
) : (
|
|
35
37
|
<Text style={styles.icon}>📱</Text>
|
|
36
38
|
)}
|
|
@@ -53,7 +55,7 @@ export const OnboardingSlide: React.FC<OnboardingSlideProps> = ({ slide }) => {
|
|
|
53
55
|
);
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
const getStyles = () =>
|
|
58
|
+
const getStyles = (tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
57
59
|
StyleSheet.create({
|
|
58
60
|
container: {
|
|
59
61
|
flex: 1,
|
|
@@ -69,17 +71,31 @@ const getStyles = () =>
|
|
|
69
71
|
alignItems: "center",
|
|
70
72
|
maxWidth: 400,
|
|
71
73
|
width: "100%",
|
|
74
|
+
// Add background for readability with theme colors
|
|
75
|
+
backgroundColor: tokens.colors.surface,
|
|
76
|
+
padding: 30,
|
|
77
|
+
borderRadius: 24,
|
|
78
|
+
borderWidth: 1,
|
|
79
|
+
borderColor: tokens.colors.borderLight,
|
|
80
|
+
shadowColor: tokens.colors.textPrimary,
|
|
81
|
+
shadowOffset: {
|
|
82
|
+
width: 0,
|
|
83
|
+
height: 4,
|
|
84
|
+
},
|
|
85
|
+
shadowOpacity: 0.1,
|
|
86
|
+
shadowRadius: 8,
|
|
87
|
+
elevation: 4,
|
|
72
88
|
},
|
|
73
89
|
iconContainer: {
|
|
74
90
|
width: 120,
|
|
75
91
|
height: 120,
|
|
76
92
|
borderRadius: 60,
|
|
77
|
-
backgroundColor:
|
|
93
|
+
backgroundColor: withAlpha(tokens.colors.primary, 0.2),
|
|
78
94
|
alignItems: "center",
|
|
79
95
|
justifyContent: "center",
|
|
80
96
|
marginBottom: 40,
|
|
81
97
|
borderWidth: 2,
|
|
82
|
-
borderColor:
|
|
98
|
+
borderColor: withAlpha(tokens.colors.primary, 0.4),
|
|
83
99
|
},
|
|
84
100
|
icon: {
|
|
85
101
|
fontSize: 60,
|
|
@@ -87,13 +103,13 @@ const getStyles = () =>
|
|
|
87
103
|
title: {
|
|
88
104
|
fontSize: 28,
|
|
89
105
|
fontWeight: "bold",
|
|
90
|
-
color:
|
|
106
|
+
color: tokens.colors.textPrimary,
|
|
91
107
|
textAlign: "center",
|
|
92
108
|
marginBottom: 16,
|
|
93
109
|
},
|
|
94
110
|
description: {
|
|
95
111
|
fontSize: 16,
|
|
96
|
-
color:
|
|
112
|
+
color: tokens.colors.textSecondary,
|
|
97
113
|
textAlign: "center",
|
|
98
114
|
lineHeight: 24,
|
|
99
115
|
marginBottom: 20,
|
|
@@ -108,7 +124,7 @@ const getStyles = () =>
|
|
|
108
124
|
marginBottom: 12,
|
|
109
125
|
},
|
|
110
126
|
featureBullet: {
|
|
111
|
-
color:
|
|
127
|
+
color: tokens.colors.primary,
|
|
112
128
|
fontSize: 20,
|
|
113
129
|
marginRight: 12,
|
|
114
130
|
marginTop: 2,
|
|
@@ -116,7 +132,7 @@ const getStyles = () =>
|
|
|
116
132
|
featureText: {
|
|
117
133
|
flex: 1,
|
|
118
134
|
fontSize: 15,
|
|
119
|
-
color:
|
|
135
|
+
color: tokens.colors.textSecondary,
|
|
120
136
|
lineHeight: 22,
|
|
121
137
|
},
|
|
122
138
|
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import React, { useMemo } from "react";
|
|
8
8
|
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
|
9
9
|
import { AtomicIcon } from "@umituz/react-native-design-system-atoms";
|
|
10
|
+
import { useAppDesignTokens, withAlpha } from "@umituz/react-native-design-system-theme";
|
|
10
11
|
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
11
12
|
import { SingleChoiceQuestion } from "./questions/SingleChoiceQuestion";
|
|
12
13
|
import { MultipleChoiceQuestion } from "./questions/MultipleChoiceQuestion";
|
|
@@ -25,7 +26,8 @@ export const QuestionSlide: React.FC<QuestionSlideProps> = ({
|
|
|
25
26
|
value,
|
|
26
27
|
onChange,
|
|
27
28
|
}) => {
|
|
28
|
-
const
|
|
29
|
+
const tokens = useAppDesignTokens();
|
|
30
|
+
const styles = useMemo(() => getStyles(tokens), [tokens]);
|
|
29
31
|
const { question } = slide;
|
|
30
32
|
|
|
31
33
|
if (!question) {
|
|
@@ -96,7 +98,7 @@ export const QuestionSlide: React.FC<QuestionSlideProps> = ({
|
|
|
96
98
|
<AtomicIcon
|
|
97
99
|
name={slide.icon as any}
|
|
98
100
|
customSize={48}
|
|
99
|
-
customColor=
|
|
101
|
+
customColor={tokens.colors.textPrimary}
|
|
100
102
|
/>
|
|
101
103
|
)}
|
|
102
104
|
</View>
|
|
@@ -121,7 +123,7 @@ export const QuestionSlide: React.FC<QuestionSlideProps> = ({
|
|
|
121
123
|
);
|
|
122
124
|
};
|
|
123
125
|
|
|
124
|
-
const getStyles = () =>
|
|
126
|
+
const getStyles = (tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
125
127
|
StyleSheet.create({
|
|
126
128
|
content: {
|
|
127
129
|
flexGrow: 1,
|
|
@@ -134,17 +136,31 @@ const getStyles = () =>
|
|
|
134
136
|
alignItems: "center",
|
|
135
137
|
maxWidth: 500,
|
|
136
138
|
width: "100%",
|
|
139
|
+
// Add background for readability with theme colors
|
|
140
|
+
backgroundColor: tokens.colors.surface,
|
|
141
|
+
padding: 30,
|
|
142
|
+
borderRadius: 24,
|
|
143
|
+
borderWidth: 1,
|
|
144
|
+
borderColor: tokens.colors.borderLight,
|
|
145
|
+
shadowColor: tokens.colors.textPrimary,
|
|
146
|
+
shadowOffset: {
|
|
147
|
+
width: 0,
|
|
148
|
+
height: 4,
|
|
149
|
+
},
|
|
150
|
+
shadowOpacity: 0.1,
|
|
151
|
+
shadowRadius: 8,
|
|
152
|
+
elevation: 4,
|
|
137
153
|
},
|
|
138
154
|
iconContainer: {
|
|
139
155
|
width: 96,
|
|
140
156
|
height: 96,
|
|
141
157
|
borderRadius: 48,
|
|
142
|
-
backgroundColor:
|
|
158
|
+
backgroundColor: withAlpha(tokens.colors.primary, 0.2),
|
|
143
159
|
alignItems: "center",
|
|
144
160
|
justifyContent: "center",
|
|
145
161
|
marginBottom: 24,
|
|
146
162
|
borderWidth: 2,
|
|
147
|
-
borderColor:
|
|
163
|
+
borderColor: withAlpha(tokens.colors.primary, 0.4),
|
|
148
164
|
},
|
|
149
165
|
icon: {
|
|
150
166
|
fontSize: 48,
|
|
@@ -152,13 +168,13 @@ const getStyles = () =>
|
|
|
152
168
|
title: {
|
|
153
169
|
fontSize: 24,
|
|
154
170
|
fontWeight: "bold",
|
|
155
|
-
color:
|
|
171
|
+
color: tokens.colors.textPrimary,
|
|
156
172
|
textAlign: "center",
|
|
157
173
|
marginBottom: 12,
|
|
158
174
|
},
|
|
159
175
|
description: {
|
|
160
176
|
fontSize: 15,
|
|
161
|
-
color:
|
|
177
|
+
color: tokens.colors.textSecondary,
|
|
162
178
|
textAlign: "center",
|
|
163
179
|
lineHeight: 22,
|
|
164
180
|
marginBottom: 24,
|
|
@@ -169,7 +185,7 @@ const getStyles = () =>
|
|
|
169
185
|
},
|
|
170
186
|
requiredHint: {
|
|
171
187
|
fontSize: 13,
|
|
172
|
-
color:
|
|
188
|
+
color: tokens.colors.textSecondary,
|
|
173
189
|
fontStyle: "italic",
|
|
174
190
|
marginTop: 12,
|
|
175
191
|
},
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Onboarding Screen
|
|
3
3
|
*
|
|
4
|
-
* Main onboarding screen component with
|
|
4
|
+
* Main onboarding screen component with theme-aware colors
|
|
5
5
|
* Generic and reusable across hundreds of apps
|
|
6
|
+
*
|
|
7
|
+
* This component only handles UI coordination - no business logic
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import React, { useMemo
|
|
10
|
+
import React, { useMemo } from "react";
|
|
9
11
|
import { View, StyleSheet, StatusBar } from "react-native";
|
|
10
12
|
import { LinearGradient } from "expo-linear-gradient";
|
|
11
13
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
14
|
+
import { useAppDesignTokens, useTheme } from "@umituz/react-native-design-system-theme";
|
|
12
15
|
import type { OnboardingOptions } from "../../domain/entities/OnboardingOptions";
|
|
13
16
|
import { useOnboardingNavigation } from "../../infrastructure/hooks/useOnboardingNavigation";
|
|
17
|
+
import { useOnboardingAnswers } from "../../infrastructure/hooks/useOnboardingAnswers";
|
|
14
18
|
import { useOnboardingStore } from "../../infrastructure/storage/OnboardingStore";
|
|
19
|
+
import { OnboardingSlideService } from "../../infrastructure/services/OnboardingSlideService";
|
|
20
|
+
import { OnboardingValidationService } from "../../infrastructure/services/OnboardingValidationService";
|
|
21
|
+
import { shouldUseGradient } from "../../infrastructure/utils/gradientUtils";
|
|
15
22
|
import { OnboardingHeader } from "../components/OnboardingHeader";
|
|
16
23
|
import { OnboardingSlide as OnboardingSlideComponent } from "../components/OnboardingSlide";
|
|
17
24
|
import { QuestionSlide } from "../components/QuestionSlide";
|
|
@@ -55,12 +62,24 @@ export interface OnboardingScreenProps extends OnboardingOptions {
|
|
|
55
62
|
* When true, shows premium paywall before completing onboarding
|
|
56
63
|
*/
|
|
57
64
|
showPaywallOnComplete?: boolean;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Enable device tracking (default: false)
|
|
68
|
+
* When enabled, collects device information during onboarding
|
|
69
|
+
*/
|
|
70
|
+
enableDeviceTracking?: boolean;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* User ID for device tracking (optional)
|
|
74
|
+
* Only used when enableDeviceTracking is true
|
|
75
|
+
*/
|
|
76
|
+
userId?: string;
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
/**
|
|
61
80
|
* Onboarding Screen Component
|
|
62
81
|
*
|
|
63
|
-
* Displays onboarding flow with
|
|
82
|
+
* Displays onboarding flow with theme-aware colors, animations, and navigation
|
|
64
83
|
*/
|
|
65
84
|
export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
|
|
66
85
|
slides,
|
|
@@ -81,41 +100,21 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
|
|
|
81
100
|
renderSlide,
|
|
82
101
|
onUpgrade,
|
|
83
102
|
showPaywallOnComplete = false,
|
|
103
|
+
enableDeviceTracking = false,
|
|
104
|
+
userId,
|
|
84
105
|
}) => {
|
|
85
106
|
const insets = useSafeAreaInsets();
|
|
107
|
+
const tokens = useAppDesignTokens();
|
|
108
|
+
const { themeMode } = useTheme();
|
|
86
109
|
const onboardingStore = useOnboardingStore();
|
|
87
|
-
const [currentAnswer, setCurrentAnswer] = useState<any>(undefined);
|
|
88
110
|
|
|
89
|
-
// Filter slides
|
|
111
|
+
// Filter slides using service
|
|
90
112
|
const filteredSlides = useMemo(() => {
|
|
91
113
|
const userData = onboardingStore.getUserData();
|
|
92
|
-
return
|
|
93
|
-
if (slide.skipIf) {
|
|
94
|
-
return !slide.skipIf(userData.answers);
|
|
95
|
-
}
|
|
96
|
-
return true;
|
|
97
|
-
});
|
|
114
|
+
return OnboardingSlideService.filterSlides(slides, userData);
|
|
98
115
|
}, [slides, onboardingStore]);
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
// Save current answer if exists
|
|
102
|
-
if (currentSlide.question && currentAnswer !== undefined) {
|
|
103
|
-
await onboardingStore.saveAnswer(currentSlide.question.id, currentAnswer);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
await onboardingStore.complete(storageKey);
|
|
107
|
-
if (onComplete) {
|
|
108
|
-
await onComplete();
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const handleSkip = async () => {
|
|
113
|
-
await onboardingStore.skip(storageKey);
|
|
114
|
-
if (onSkip) {
|
|
115
|
-
await onSkip();
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
117
|
+
// Navigation hook
|
|
119
118
|
const {
|
|
120
119
|
currentIndex,
|
|
121
120
|
goToNext,
|
|
@@ -124,108 +123,97 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
|
|
|
124
123
|
skip: skipOnboarding,
|
|
125
124
|
isLastSlide,
|
|
126
125
|
isFirstSlide,
|
|
127
|
-
} = useOnboardingNavigation(
|
|
126
|
+
} = useOnboardingNavigation(
|
|
127
|
+
filteredSlides.length,
|
|
128
|
+
async () => {
|
|
129
|
+
await onboardingStore.complete(storageKey, { enableDeviceTracking, userId });
|
|
130
|
+
if (onComplete) {
|
|
131
|
+
await onComplete();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async () => {
|
|
135
|
+
await onboardingStore.skip(storageKey);
|
|
136
|
+
if (onSkip) {
|
|
137
|
+
await onSkip();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Get current slide
|
|
143
|
+
const currentSlide = useMemo(
|
|
144
|
+
() => OnboardingSlideService.getSlideAtIndex(filteredSlides, currentIndex),
|
|
145
|
+
[filteredSlides, currentIndex],
|
|
146
|
+
);
|
|
128
147
|
|
|
148
|
+
// Answer management hook
|
|
149
|
+
const {
|
|
150
|
+
currentAnswer,
|
|
151
|
+
setCurrentAnswer,
|
|
152
|
+
loadAnswerForSlide,
|
|
153
|
+
saveCurrentAnswer,
|
|
154
|
+
} = useOnboardingAnswers(currentSlide);
|
|
155
|
+
|
|
156
|
+
// Handle next slide
|
|
129
157
|
const handleNext = async () => {
|
|
130
|
-
|
|
131
|
-
if (currentSlide.question && currentAnswer !== undefined) {
|
|
132
|
-
await onboardingStore.saveAnswer(currentSlide.question.id, currentAnswer);
|
|
133
|
-
}
|
|
158
|
+
await saveCurrentAnswer(currentSlide);
|
|
134
159
|
|
|
135
160
|
if (isLastSlide) {
|
|
136
|
-
// Use useOnboardingNavigation's complete function
|
|
137
|
-
// This will call handleComplete callback and emit event
|
|
138
161
|
await completeOnboarding();
|
|
139
162
|
} else {
|
|
140
163
|
goToNext();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
} else {
|
|
147
|
-
setCurrentAnswer(undefined);
|
|
148
|
-
}
|
|
164
|
+
const nextSlide = OnboardingSlideService.getSlideAtIndex(
|
|
165
|
+
filteredSlides,
|
|
166
|
+
currentIndex + 1,
|
|
167
|
+
);
|
|
168
|
+
loadAnswerForSlide(nextSlide);
|
|
149
169
|
}
|
|
150
170
|
};
|
|
151
171
|
|
|
172
|
+
// Handle previous slide
|
|
152
173
|
const handlePrevious = () => {
|
|
153
174
|
goToPrevious();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
setCurrentAnswer(savedAnswer);
|
|
160
|
-
} else {
|
|
161
|
-
setCurrentAnswer(undefined);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
175
|
+
const prevSlide = OnboardingSlideService.getSlideAtIndex(
|
|
176
|
+
filteredSlides,
|
|
177
|
+
currentIndex - 1,
|
|
178
|
+
);
|
|
179
|
+
loadAnswerForSlide(prevSlide);
|
|
164
180
|
};
|
|
165
181
|
|
|
166
|
-
|
|
167
|
-
const
|
|
182
|
+
// Handle skip
|
|
183
|
+
const handleSkip = async () => {
|
|
184
|
+
await skipOnboarding();
|
|
185
|
+
};
|
|
168
186
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
if (currentSlide?.question) {
|
|
172
|
-
const savedAnswer = onboardingStore.getAnswer(currentSlide.question.id);
|
|
173
|
-
setCurrentAnswer(savedAnswer ?? currentSlide.question.defaultValue);
|
|
174
|
-
} else {
|
|
175
|
-
setCurrentAnswer(undefined);
|
|
176
|
-
}
|
|
177
|
-
}, [currentIndex, currentSlide, onboardingStore]);
|
|
187
|
+
// Check if gradient should be used
|
|
188
|
+
const useGradient = shouldUseGradient(currentSlide);
|
|
178
189
|
|
|
179
|
-
// Validate
|
|
190
|
+
// Validate answer using service
|
|
180
191
|
const isAnswerValid = useMemo(() => {
|
|
181
|
-
if (!currentSlide?.question)
|
|
182
|
-
|
|
183
|
-
const { validation } = currentSlide.question;
|
|
184
|
-
if (!validation) return true;
|
|
185
|
-
|
|
186
|
-
// Required validation
|
|
187
|
-
if (validation.required && !currentAnswer) return false;
|
|
188
|
-
|
|
189
|
-
// Type-specific validations
|
|
190
|
-
switch (currentSlide.question.type) {
|
|
191
|
-
case "multiple_choice":
|
|
192
|
-
if (validation.minSelections && (!currentAnswer || currentAnswer.length < validation.minSelections)) {
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
break;
|
|
196
|
-
case "text_input":
|
|
197
|
-
if (validation.minLength && (!currentAnswer || currentAnswer.length < validation.minLength)) {
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
break;
|
|
201
|
-
case "slider":
|
|
202
|
-
case "rating":
|
|
203
|
-
if (validation.min !== undefined && currentAnswer < validation.min) {
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
if (validation.max !== undefined && currentAnswer > validation.max) {
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Custom validator
|
|
213
|
-
if (validation.customValidator) {
|
|
214
|
-
return validation.customValidator(currentAnswer) === true;
|
|
192
|
+
if (!currentSlide?.question) {
|
|
193
|
+
return true;
|
|
215
194
|
}
|
|
216
|
-
|
|
217
|
-
|
|
195
|
+
return OnboardingValidationService.validateAnswer(
|
|
196
|
+
currentSlide.question,
|
|
197
|
+
currentAnswer,
|
|
198
|
+
);
|
|
218
199
|
}, [currentSlide, currentAnswer]);
|
|
219
200
|
|
|
201
|
+
const styles = useMemo(
|
|
202
|
+
() => getStyles(insets, tokens, useGradient),
|
|
203
|
+
[insets, tokens, useGradient],
|
|
204
|
+
);
|
|
205
|
+
|
|
220
206
|
return (
|
|
221
207
|
<View style={styles.container}>
|
|
222
|
-
<StatusBar barStyle="light-content" />
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
208
|
+
<StatusBar barStyle={themeMode === "dark" ? "light-content" : "dark-content"} />
|
|
209
|
+
{useGradient && currentSlide && (
|
|
210
|
+
<LinearGradient
|
|
211
|
+
colors={currentSlide.gradient as [string, string, ...string[]]}
|
|
212
|
+
start={{ x: 0, y: 0 }}
|
|
213
|
+
end={{ x: 1, y: 1 }}
|
|
214
|
+
style={StyleSheet.absoluteFill}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
229
217
|
{renderHeader ? (
|
|
230
218
|
renderHeader({
|
|
231
219
|
isFirstSlide,
|
|
@@ -240,18 +228,21 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
|
|
|
240
228
|
showBackButton={showBackButton}
|
|
241
229
|
showSkipButton={showSkipButton}
|
|
242
230
|
skipButtonText={skipButtonText}
|
|
231
|
+
useGradient={useGradient}
|
|
243
232
|
/>
|
|
244
233
|
)}
|
|
245
|
-
{
|
|
246
|
-
renderSlide(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
234
|
+
{currentSlide && (
|
|
235
|
+
renderSlide ? (
|
|
236
|
+
renderSlide(currentSlide)
|
|
237
|
+
) : currentSlide.type === "question" && currentSlide.question ? (
|
|
238
|
+
<QuestionSlide
|
|
239
|
+
slide={currentSlide}
|
|
240
|
+
value={currentAnswer}
|
|
241
|
+
onChange={setCurrentAnswer}
|
|
242
|
+
/>
|
|
243
|
+
) : (
|
|
244
|
+
<OnboardingSlideComponent slide={currentSlide} />
|
|
245
|
+
)
|
|
255
246
|
)}
|
|
256
247
|
{renderFooter ? (
|
|
257
248
|
renderFooter({
|
|
@@ -274,17 +265,24 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
|
|
|
274
265
|
nextButtonText={nextButtonText}
|
|
275
266
|
getStartedButtonText={getStartedButtonText}
|
|
276
267
|
disabled={!isAnswerValid}
|
|
268
|
+
useGradient={useGradient}
|
|
277
269
|
/>
|
|
278
270
|
)}
|
|
279
271
|
</View>
|
|
280
272
|
);
|
|
281
273
|
};
|
|
282
274
|
|
|
283
|
-
const getStyles = (
|
|
275
|
+
const getStyles = (
|
|
276
|
+
insets: { top: number },
|
|
277
|
+
tokens: ReturnType<typeof useAppDesignTokens>,
|
|
278
|
+
useGradient: boolean,
|
|
279
|
+
) =>
|
|
284
280
|
StyleSheet.create({
|
|
285
281
|
container: {
|
|
286
282
|
flex: 1,
|
|
287
283
|
paddingTop: insets.top,
|
|
284
|
+
// Use transparent background when gradient is used, otherwise use theme background
|
|
285
|
+
backgroundColor: useGradient ? 'transparent' : tokens.colors.backgroundPrimary,
|
|
288
286
|
},
|
|
289
287
|
});
|
|
290
288
|
|