noboarding 1.0.2-beta → 1.0.6-beta
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/ANIMATIONS.md +446 -0
- package/README.md +356 -323
- package/lib/OnboardingFlow.js +106 -14
- package/lib/analytics.d.ts +6 -0
- package/lib/analytics.js +18 -1
- package/lib/animationUtils.d.ts +19 -0
- package/lib/animationUtils.js +252 -0
- package/lib/components/ElementRenderer.d.ts +5 -0
- package/lib/components/ElementRenderer.js +196 -36
- package/lib/types.d.ts +47 -0
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +116 -14
- package/src/analytics.ts +23 -1
- package/src/animationUtils.ts +292 -0
- package/src/components/ElementRenderer.tsx +233 -18
- package/src/types.ts +52 -0
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
2
|
import { View, ActivityIndicator, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
|
3
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
3
4
|
import { API } from './api';
|
|
4
5
|
import { AnalyticsManager } from './analytics';
|
|
5
6
|
import { OnboardingFlowProps, ScreenConfig, ConditionalDestination, ConditionalRoutes } from './types';
|
|
6
7
|
import { resolveDestination } from './variableUtils';
|
|
7
8
|
import { ElementRenderer } from './components/ElementRenderer';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const USER_ID_STORAGE_KEY = '@noboarding_user_id';
|
|
11
|
+
|
|
12
|
+
// Get or create persistent user ID
|
|
13
|
+
const getPersistentUserId = async (): Promise<string> => {
|
|
14
|
+
try {
|
|
15
|
+
// Try to get existing user ID from storage
|
|
16
|
+
const existingUserId = await AsyncStorage.getItem(USER_ID_STORAGE_KEY);
|
|
17
|
+
|
|
18
|
+
if (existingUserId) {
|
|
19
|
+
return existingUserId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Generate new user ID if none exists
|
|
23
|
+
const newUserId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
24
|
+
await AsyncStorage.setItem(USER_ID_STORAGE_KEY, newUserId);
|
|
25
|
+
return newUserId;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Failed to get/set user ID from storage:', error);
|
|
28
|
+
// Fallback to generating a non-persistent ID
|
|
29
|
+
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
30
|
+
}
|
|
12
31
|
};
|
|
13
32
|
|
|
33
|
+
// Generate session ID (always new per session)
|
|
14
34
|
const generateSessionId = (): string => {
|
|
15
35
|
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
16
36
|
};
|
|
@@ -54,22 +74,33 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
54
74
|
const [loading, setLoading] = useState(true);
|
|
55
75
|
const [error, setError] = useState<string | null>(null);
|
|
56
76
|
const [screens, setScreens] = useState<ScreenConfig[]>([]);
|
|
77
|
+
const [assets, setAssets] = useState<Array<{ name: string; type: string; data: string }>>([]);
|
|
57
78
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
58
79
|
const [collectedData, setCollectedData] = useState<Record<string, any>>({});
|
|
59
80
|
const [variables, setVariables] = useState<Record<string, any>>(initialVariables || {});
|
|
60
81
|
|
|
61
82
|
const apiRef = useRef<API | null>(null);
|
|
62
83
|
const analyticsRef = useRef<AnalyticsManager | null>(null);
|
|
63
|
-
const userIdRef = useRef<string>(
|
|
84
|
+
const userIdRef = useRef<string | null>(null);
|
|
64
85
|
const sessionIdRef = useRef<string>(generateSessionId());
|
|
86
|
+
const flowIdRef = useRef<string | null>(null);
|
|
65
87
|
|
|
66
88
|
useEffect(() => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
const initialize = async () => {
|
|
90
|
+
// Get or create persistent user ID
|
|
91
|
+
const userId = await getPersistentUserId();
|
|
92
|
+
userIdRef.current = userId;
|
|
93
|
+
|
|
94
|
+
// Notify parent of the user ID
|
|
95
|
+
if (onUserIdGenerated) {
|
|
96
|
+
onUserIdGenerated(userId);
|
|
97
|
+
}
|
|
71
98
|
|
|
72
|
-
|
|
99
|
+
// Initialize the flow
|
|
100
|
+
await initializeFlow();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
initialize();
|
|
73
104
|
|
|
74
105
|
return () => {
|
|
75
106
|
// Cleanup: flush remaining analytics
|
|
@@ -79,11 +110,28 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
79
110
|
};
|
|
80
111
|
}, []);
|
|
81
112
|
|
|
113
|
+
// Track screen views whenever user navigates to a different screen
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (screens.length > 0 && analyticsRef.current && currentIndex > 0) {
|
|
116
|
+
const currentScreen = screens[currentIndex];
|
|
117
|
+
analyticsRef.current.track('screen_viewed', {
|
|
118
|
+
flow_id: flowIdRef.current,
|
|
119
|
+
screen_id: currentScreen.id,
|
|
120
|
+
screen_index: currentIndex,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}, [currentIndex, screens]);
|
|
124
|
+
|
|
82
125
|
const initializeFlow = async () => {
|
|
83
126
|
try {
|
|
84
127
|
setLoading(true);
|
|
85
128
|
setError(null);
|
|
86
129
|
|
|
130
|
+
// Ensure user ID is set
|
|
131
|
+
if (!userIdRef.current) {
|
|
132
|
+
throw new Error('User ID not initialized');
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
// Initialize API client with detected API key
|
|
88
136
|
const api = new API(activeApiKey, baseUrl);
|
|
89
137
|
apiRef.current = api;
|
|
@@ -96,13 +144,55 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
96
144
|
);
|
|
97
145
|
analyticsRef.current = analytics;
|
|
98
146
|
|
|
99
|
-
// Track onboarding start
|
|
100
|
-
analytics.track('onboarding_started');
|
|
101
|
-
|
|
102
147
|
// Fetch configuration
|
|
103
148
|
const configResponse = await api.getConfig();
|
|
149
|
+
|
|
150
|
+
// Store flow_id for analytics
|
|
151
|
+
flowIdRef.current = configResponse.config_id;
|
|
152
|
+
|
|
153
|
+
// Store assets from config
|
|
154
|
+
if (configResponse.config.assets) {
|
|
155
|
+
setAssets(configResponse.config.assets);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Handle A/B test experiment assignment
|
|
159
|
+
let screensToUse = configResponse.config.screens;
|
|
160
|
+
|
|
161
|
+
if (configResponse.experiments && configResponse.experiments.length > 0) {
|
|
162
|
+
// Assign user to the first active experiment
|
|
163
|
+
const experiment = configResponse.experiments[0];
|
|
164
|
+
try {
|
|
165
|
+
const assignment = await api.assignVariant(
|
|
166
|
+
experiment.id,
|
|
167
|
+
userIdRef.current!
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
console.log('🧪 A/B Test Assignment:', {
|
|
171
|
+
experiment_id: experiment.id,
|
|
172
|
+
experiment_name: experiment.name,
|
|
173
|
+
variant_id: assignment.variant_id,
|
|
174
|
+
has_variant_screens: assignment.variant_config?.screens?.length > 0,
|
|
175
|
+
variant_screen_count: assignment.variant_config?.screens?.length || 0,
|
|
176
|
+
cached: assignment.cached,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Set experiment context so all events get tagged
|
|
180
|
+
analytics.setExperimentContext(experiment.id, assignment.variant_id);
|
|
181
|
+
|
|
182
|
+
// Use variant screens if available
|
|
183
|
+
if (assignment.variant_config?.screens?.length > 0) {
|
|
184
|
+
screensToUse = assignment.variant_config.screens;
|
|
185
|
+
console.log('📱 Using variant screens:', assignment.variant_config.screens.length, 'screens');
|
|
186
|
+
} else {
|
|
187
|
+
console.log('📱 Using base flow screens (variant has no screens defined)');
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.warn('Failed to assign experiment variant, using default flow:', err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
104
194
|
// Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
|
|
105
|
-
const normalizedScreens =
|
|
195
|
+
const normalizedScreens = screensToUse
|
|
106
196
|
.map(s => ({
|
|
107
197
|
...s,
|
|
108
198
|
type: (s.type === ('custom_screen' as any) && s.elements) ? 'noboard_screen' as ScreenConfig['type'] : s.type,
|
|
@@ -111,6 +201,12 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
111
201
|
.filter(s => !s.hidden);
|
|
112
202
|
setScreens(normalizedScreens);
|
|
113
203
|
|
|
204
|
+
// Track onboarding start with first screen
|
|
205
|
+
analytics.track('onboarding_started', {
|
|
206
|
+
flow_id: flowIdRef.current,
|
|
207
|
+
screen_id: normalizedScreens[0]?.id,
|
|
208
|
+
});
|
|
209
|
+
|
|
114
210
|
setLoading(false);
|
|
115
211
|
} catch (err) {
|
|
116
212
|
console.error('Failed to initialize onboarding flow:', err);
|
|
@@ -162,7 +258,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
162
258
|
|
|
163
259
|
// Track completion
|
|
164
260
|
if (analyticsRef.current) {
|
|
165
|
-
analyticsRef.current.track('onboarding_completed'
|
|
261
|
+
analyticsRef.current.track('onboarding_completed', {
|
|
262
|
+
flow_id: flowIdRef.current,
|
|
263
|
+
screen_id: screens[currentIndex]?.id,
|
|
264
|
+
});
|
|
166
265
|
await analyticsRef.current.flush();
|
|
167
266
|
}
|
|
168
267
|
|
|
@@ -173,6 +272,8 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
173
272
|
const handleSkipAll = async () => {
|
|
174
273
|
if (analyticsRef.current) {
|
|
175
274
|
analyticsRef.current.track('onboarding_abandoned', {
|
|
275
|
+
flow_id: flowIdRef.current,
|
|
276
|
+
screen_id: screens[currentIndex]?.id,
|
|
176
277
|
current_screen_index: currentIndex,
|
|
177
278
|
});
|
|
178
279
|
await analyticsRef.current.flush();
|
|
@@ -256,6 +357,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
256
357
|
onDismiss={onSkip ? handleSkipAll : handleNext}
|
|
257
358
|
variables={allVariables}
|
|
258
359
|
onSetVariable={handleSetVariable}
|
|
360
|
+
assets={assets}
|
|
259
361
|
/>
|
|
260
362
|
</View>
|
|
261
363
|
);
|
package/src/analytics.ts
CHANGED
|
@@ -10,6 +10,8 @@ export class AnalyticsManager {
|
|
|
10
10
|
private userId: string;
|
|
11
11
|
private sessionId: string;
|
|
12
12
|
private flushTimer: NodeJS.Timeout | null = null;
|
|
13
|
+
private experimentId: string | null = null;
|
|
14
|
+
private variantId: string | null = null;
|
|
13
15
|
|
|
14
16
|
constructor(api: API, userId: string, sessionId: string) {
|
|
15
17
|
this.api = api;
|
|
@@ -20,16 +22,36 @@ export class AnalyticsManager {
|
|
|
20
22
|
this.startFlushTimer();
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Set experiment context — all subsequent events will be tagged
|
|
27
|
+
*/
|
|
28
|
+
setExperimentContext(experimentId: string, variantId: string): void {
|
|
29
|
+
this.experimentId = experimentId;
|
|
30
|
+
this.variantId = variantId;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* Track an analytics event
|
|
25
35
|
*/
|
|
26
36
|
track(eventName: string, properties?: Record<string, any>): void {
|
|
37
|
+
const mergedProperties: Record<string, any> = {
|
|
38
|
+
...(properties || {}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Auto-inject experiment context if set
|
|
42
|
+
if (this.experimentId) {
|
|
43
|
+
mergedProperties.experiment_id = this.experimentId;
|
|
44
|
+
}
|
|
45
|
+
if (this.variantId) {
|
|
46
|
+
mergedProperties.variant_id = this.variantId;
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
const event: AnalyticsEvent = {
|
|
28
50
|
event: eventName,
|
|
29
51
|
user_id: this.userId,
|
|
30
52
|
session_id: this.sessionId,
|
|
31
53
|
timestamp: Date.now(),
|
|
32
|
-
properties:
|
|
54
|
+
properties: mergedProperties,
|
|
33
55
|
};
|
|
34
56
|
|
|
35
57
|
this.events.push(event);
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { Animated, Easing } from 'react-native';
|
|
2
|
+
import type {
|
|
3
|
+
EntranceAnimation,
|
|
4
|
+
InteractiveAnimation,
|
|
5
|
+
TextAnimation,
|
|
6
|
+
HapticType
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
// Lazy load haptics to avoid errors if not installed
|
|
10
|
+
let Haptics: any = null;
|
|
11
|
+
try {
|
|
12
|
+
Haptics = require('expo-haptics');
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.warn('expo-haptics not installed, haptic feedback will be disabled');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Haptic Feedback Helper ───
|
|
18
|
+
|
|
19
|
+
export const triggerHaptic = (type: HapticType = 'light') => {
|
|
20
|
+
if (!Haptics) {
|
|
21
|
+
// Silently skip if expo-haptics not available
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
switch (type) {
|
|
27
|
+
case 'light':
|
|
28
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
29
|
+
break;
|
|
30
|
+
case 'medium':
|
|
31
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
32
|
+
break;
|
|
33
|
+
case 'heavy':
|
|
34
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
|
35
|
+
break;
|
|
36
|
+
case 'success':
|
|
37
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
38
|
+
break;
|
|
39
|
+
case 'warning':
|
|
40
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
41
|
+
break;
|
|
42
|
+
case 'error':
|
|
43
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('Haptic feedback failed:', error);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── Easing Function Mapper ───
|
|
52
|
+
|
|
53
|
+
export const getEasing = (easingType?: string) => {
|
|
54
|
+
switch (easingType) {
|
|
55
|
+
case 'linear':
|
|
56
|
+
return Easing.linear;
|
|
57
|
+
case 'ease-in':
|
|
58
|
+
return Easing.in(Easing.ease);
|
|
59
|
+
case 'ease-out':
|
|
60
|
+
return Easing.out(Easing.ease);
|
|
61
|
+
case 'ease-in-out':
|
|
62
|
+
return Easing.inOut(Easing.ease);
|
|
63
|
+
case 'spring':
|
|
64
|
+
return Easing.elastic(1);
|
|
65
|
+
default:
|
|
66
|
+
return Easing.inOut(Easing.ease);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ─── Entrance Animations ───
|
|
71
|
+
|
|
72
|
+
export interface EntranceAnimationValues {
|
|
73
|
+
opacity: Animated.Value;
|
|
74
|
+
translateY: Animated.Value;
|
|
75
|
+
translateX: Animated.Value;
|
|
76
|
+
scale: Animated.Value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const createEntranceAnimationValues = (): EntranceAnimationValues => ({
|
|
80
|
+
opacity: new Animated.Value(0),
|
|
81
|
+
translateY: new Animated.Value(0),
|
|
82
|
+
translateX: new Animated.Value(0),
|
|
83
|
+
scale: new Animated.Value(1),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const startEntranceAnimation = (
|
|
87
|
+
config: EntranceAnimation,
|
|
88
|
+
values: EntranceAnimationValues,
|
|
89
|
+
delay: number = 0
|
|
90
|
+
): void => {
|
|
91
|
+
const duration = config.duration || 400;
|
|
92
|
+
const totalDelay = (config.delay || 0) + delay;
|
|
93
|
+
const easing = getEasing(config.easing);
|
|
94
|
+
|
|
95
|
+
// Set initial values based on animation type
|
|
96
|
+
switch (config.type) {
|
|
97
|
+
case 'fadeIn':
|
|
98
|
+
values.opacity.setValue(0);
|
|
99
|
+
break;
|
|
100
|
+
case 'slideUp':
|
|
101
|
+
values.opacity.setValue(0);
|
|
102
|
+
values.translateY.setValue(30);
|
|
103
|
+
break;
|
|
104
|
+
case 'slideDown':
|
|
105
|
+
values.opacity.setValue(0);
|
|
106
|
+
values.translateY.setValue(-30);
|
|
107
|
+
break;
|
|
108
|
+
case 'slideLeft':
|
|
109
|
+
values.opacity.setValue(0);
|
|
110
|
+
values.translateX.setValue(30);
|
|
111
|
+
break;
|
|
112
|
+
case 'slideRight':
|
|
113
|
+
values.opacity.setValue(0);
|
|
114
|
+
values.translateX.setValue(-30);
|
|
115
|
+
break;
|
|
116
|
+
case 'scaleIn':
|
|
117
|
+
values.opacity.setValue(0);
|
|
118
|
+
values.scale.setValue(0.8);
|
|
119
|
+
break;
|
|
120
|
+
case 'none':
|
|
121
|
+
values.opacity.setValue(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Animate to final values
|
|
126
|
+
Animated.parallel([
|
|
127
|
+
Animated.timing(values.opacity, {
|
|
128
|
+
toValue: 1,
|
|
129
|
+
duration,
|
|
130
|
+
delay: totalDelay,
|
|
131
|
+
easing,
|
|
132
|
+
useNativeDriver: true,
|
|
133
|
+
}),
|
|
134
|
+
Animated.timing(values.translateY, {
|
|
135
|
+
toValue: 0,
|
|
136
|
+
duration,
|
|
137
|
+
delay: totalDelay,
|
|
138
|
+
easing,
|
|
139
|
+
useNativeDriver: true,
|
|
140
|
+
}),
|
|
141
|
+
Animated.timing(values.translateX, {
|
|
142
|
+
toValue: 0,
|
|
143
|
+
duration,
|
|
144
|
+
delay: totalDelay,
|
|
145
|
+
easing,
|
|
146
|
+
useNativeDriver: true,
|
|
147
|
+
}),
|
|
148
|
+
Animated.timing(values.scale, {
|
|
149
|
+
toValue: 1,
|
|
150
|
+
duration,
|
|
151
|
+
delay: totalDelay,
|
|
152
|
+
easing,
|
|
153
|
+
useNativeDriver: true,
|
|
154
|
+
}),
|
|
155
|
+
]).start();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ─── Interactive Animations ───
|
|
159
|
+
|
|
160
|
+
export const startInteractiveAnimation = (
|
|
161
|
+
config: InteractiveAnimation,
|
|
162
|
+
animatedValue: Animated.Value
|
|
163
|
+
): void => {
|
|
164
|
+
const duration = config.duration || 200;
|
|
165
|
+
const intensity = config.intensity || (config.type === 'scale' ? 0.95 : 10);
|
|
166
|
+
|
|
167
|
+
// Trigger haptic if enabled
|
|
168
|
+
if (config.haptic && config.hapticType) {
|
|
169
|
+
triggerHaptic(config.hapticType);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
switch (config.type) {
|
|
173
|
+
case 'scale':
|
|
174
|
+
Animated.sequence([
|
|
175
|
+
Animated.timing(animatedValue, {
|
|
176
|
+
toValue: intensity,
|
|
177
|
+
duration: duration / 2,
|
|
178
|
+
easing: Easing.out(Easing.ease),
|
|
179
|
+
useNativeDriver: true,
|
|
180
|
+
}),
|
|
181
|
+
Animated.timing(animatedValue, {
|
|
182
|
+
toValue: 1,
|
|
183
|
+
duration: duration / 2,
|
|
184
|
+
easing: Easing.in(Easing.ease),
|
|
185
|
+
useNativeDriver: true,
|
|
186
|
+
}),
|
|
187
|
+
]).start();
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case 'pulse':
|
|
191
|
+
const pulseSequence = Animated.sequence([
|
|
192
|
+
Animated.timing(animatedValue, {
|
|
193
|
+
toValue: 1.05,
|
|
194
|
+
duration: duration,
|
|
195
|
+
easing: Easing.inOut(Easing.ease),
|
|
196
|
+
useNativeDriver: true,
|
|
197
|
+
}),
|
|
198
|
+
Animated.timing(animatedValue, {
|
|
199
|
+
toValue: 1,
|
|
200
|
+
duration: duration,
|
|
201
|
+
easing: Easing.inOut(Easing.ease),
|
|
202
|
+
useNativeDriver: true,
|
|
203
|
+
}),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
if (config.repeat) {
|
|
207
|
+
Animated.loop(pulseSequence).start();
|
|
208
|
+
} else {
|
|
209
|
+
pulseSequence.start();
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case 'shake':
|
|
214
|
+
Animated.sequence([
|
|
215
|
+
Animated.timing(animatedValue, {
|
|
216
|
+
toValue: intensity,
|
|
217
|
+
duration: duration / 8,
|
|
218
|
+
useNativeDriver: true,
|
|
219
|
+
}),
|
|
220
|
+
Animated.timing(animatedValue, {
|
|
221
|
+
toValue: -intensity,
|
|
222
|
+
duration: duration / 4,
|
|
223
|
+
useNativeDriver: true,
|
|
224
|
+
}),
|
|
225
|
+
Animated.timing(animatedValue, {
|
|
226
|
+
toValue: intensity,
|
|
227
|
+
duration: duration / 4,
|
|
228
|
+
useNativeDriver: true,
|
|
229
|
+
}),
|
|
230
|
+
Animated.timing(animatedValue, {
|
|
231
|
+
toValue: -intensity,
|
|
232
|
+
duration: duration / 4,
|
|
233
|
+
useNativeDriver: true,
|
|
234
|
+
}),
|
|
235
|
+
Animated.timing(animatedValue, {
|
|
236
|
+
toValue: 0,
|
|
237
|
+
duration: duration / 8,
|
|
238
|
+
useNativeDriver: true,
|
|
239
|
+
}),
|
|
240
|
+
]).start();
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case 'bounce':
|
|
244
|
+
Animated.sequence([
|
|
245
|
+
Animated.timing(animatedValue, {
|
|
246
|
+
toValue: -20,
|
|
247
|
+
duration: duration / 3,
|
|
248
|
+
easing: Easing.out(Easing.ease),
|
|
249
|
+
useNativeDriver: true,
|
|
250
|
+
}),
|
|
251
|
+
Animated.timing(animatedValue, {
|
|
252
|
+
toValue: 5,
|
|
253
|
+
duration: duration / 3,
|
|
254
|
+
easing: Easing.in(Easing.ease),
|
|
255
|
+
useNativeDriver: true,
|
|
256
|
+
}),
|
|
257
|
+
Animated.timing(animatedValue, {
|
|
258
|
+
toValue: 0,
|
|
259
|
+
duration: duration / 3,
|
|
260
|
+
easing: Easing.out(Easing.ease),
|
|
261
|
+
useNativeDriver: true,
|
|
262
|
+
}),
|
|
263
|
+
]).start();
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ─── Typewriter Text Animation ───
|
|
269
|
+
|
|
270
|
+
export interface TypewriterState {
|
|
271
|
+
displayedText: string;
|
|
272
|
+
currentIndex: number;
|
|
273
|
+
isComplete: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const shouldTriggerHaptic = (
|
|
277
|
+
index: number,
|
|
278
|
+
frequency: string
|
|
279
|
+
): boolean => {
|
|
280
|
+
switch (frequency) {
|
|
281
|
+
case 'every':
|
|
282
|
+
return true;
|
|
283
|
+
case 'every-2':
|
|
284
|
+
return index % 2 === 0;
|
|
285
|
+
case 'every-3':
|
|
286
|
+
return index % 3 === 0;
|
|
287
|
+
case 'every-5':
|
|
288
|
+
return index % 5 === 0;
|
|
289
|
+
default:
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
};
|