noboarding 1.0.3-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 +21 -4
- 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 +46 -0
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +19 -0
- package/src/animationUtils.ts +292 -0
- package/src/components/ElementRenderer.tsx +233 -18
- package/src/types.ts +51 -0
|
@@ -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
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -11,9 +11,17 @@ import {
|
|
|
11
11
|
ViewStyle,
|
|
12
12
|
TextStyle,
|
|
13
13
|
ImageStyle,
|
|
14
|
+
Animated,
|
|
14
15
|
} from 'react-native';
|
|
15
16
|
import { ElementNode, ElementStyle, ElementAction, Analytics, ConditionalDestination, ConditionalRoutes } from '../types';
|
|
16
17
|
import { resolveTemplate, evaluateCondition } from '../variableUtils';
|
|
18
|
+
import {
|
|
19
|
+
createEntranceAnimationValues,
|
|
20
|
+
startEntranceAnimation,
|
|
21
|
+
startInteractiveAnimation,
|
|
22
|
+
triggerHaptic,
|
|
23
|
+
shouldTriggerHaptic,
|
|
24
|
+
} from '../animationUtils';
|
|
17
25
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
18
26
|
import { Feather, MaterialIcons, MaterialCommunityIcons, Ionicons, FontAwesome } from '@expo/vector-icons';
|
|
19
27
|
|
|
@@ -35,6 +43,7 @@ interface ElementRendererProps {
|
|
|
35
43
|
onDismiss?: () => void;
|
|
36
44
|
variables?: Record<string, any>;
|
|
37
45
|
onSetVariable?: (name: string, value: any) => void;
|
|
46
|
+
assets?: Array<{ name: string; type: string; data: string }>;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
@@ -45,11 +54,31 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
45
54
|
onDismiss,
|
|
46
55
|
variables = {},
|
|
47
56
|
onSetVariable,
|
|
57
|
+
assets = [],
|
|
48
58
|
}) => {
|
|
49
59
|
// Track toggled element IDs for toggle actions
|
|
50
60
|
const [toggledIds, setToggledIds] = useState<Set<string>>(new Set());
|
|
51
61
|
// Track selection groups: group name → selected element ID
|
|
52
62
|
const [groupSelections, setGroupSelections] = useState<Record<string, string>>({});
|
|
63
|
+
// Track text input values locally (uncontrolled)
|
|
64
|
+
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
65
|
+
|
|
66
|
+
// Helper function to resolve asset: URLs to actual data URLs
|
|
67
|
+
const resolveAssetUrl = (url: string): string => {
|
|
68
|
+
if (!url || !url.startsWith('asset:')) {
|
|
69
|
+
return url; // Return as-is if not an asset reference
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const assetName = url.replace('asset:', '');
|
|
73
|
+
const asset = assets.find(a => a.name === assetName);
|
|
74
|
+
|
|
75
|
+
if (asset) {
|
|
76
|
+
return asset.data; // Return the base64 data URL
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.warn(`Asset not found: ${assetName}`);
|
|
80
|
+
return url; // Fallback to original URL
|
|
81
|
+
};
|
|
53
82
|
|
|
54
83
|
const executeAction = useCallback(
|
|
55
84
|
(action: ElementAction, element: ElementNode) => {
|
|
@@ -66,6 +95,12 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
66
95
|
if (action.variable !== undefined && onSetVariable) {
|
|
67
96
|
onSetVariable(action.variable, action.value);
|
|
68
97
|
}
|
|
98
|
+
// Also save all current text input values when any action is triggered
|
|
99
|
+
if (onSetVariable) {
|
|
100
|
+
Object.entries(inputValues).forEach(([key, value]) => {
|
|
101
|
+
onSetVariable(key, value);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
69
104
|
break;
|
|
70
105
|
case 'toggle': {
|
|
71
106
|
const group = action.group;
|
|
@@ -98,6 +133,12 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
98
133
|
break;
|
|
99
134
|
}
|
|
100
135
|
case 'navigate':
|
|
136
|
+
// Save all text input values before navigating
|
|
137
|
+
if (onSetVariable) {
|
|
138
|
+
Object.entries(inputValues).forEach(([key, value]) => {
|
|
139
|
+
onSetVariable(key, value);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
101
142
|
if (onNavigate && action.destination) {
|
|
102
143
|
onNavigate(action.destination);
|
|
103
144
|
}
|
|
@@ -115,7 +156,7 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
115
156
|
break;
|
|
116
157
|
}
|
|
117
158
|
},
|
|
118
|
-
[groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable]
|
|
159
|
+
[groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable, inputValues]
|
|
119
160
|
);
|
|
120
161
|
|
|
121
162
|
const handleAction = useCallback(
|
|
@@ -148,6 +189,9 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
148
189
|
groupSelections={groupSelections}
|
|
149
190
|
onAction={handleAction}
|
|
150
191
|
variables={variables}
|
|
192
|
+
inputValues={inputValues}
|
|
193
|
+
setInputValues={setInputValues}
|
|
194
|
+
resolveAssetUrl={resolveAssetUrl}
|
|
151
195
|
/>
|
|
152
196
|
))}
|
|
153
197
|
</>
|
|
@@ -163,15 +207,39 @@ interface RenderNodeProps {
|
|
|
163
207
|
onAction: (element: ElementNode) => void;
|
|
164
208
|
variables: Record<string, any>;
|
|
165
209
|
onSetVariable?: (name: string, value: any) => void;
|
|
210
|
+
inputValues: Record<string, string>;
|
|
211
|
+
setInputValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
212
|
+
staggerDelay?: number; // For staggered entrance animations
|
|
213
|
+
resolveAssetUrl: (url: string) => string; // Asset URL resolver
|
|
166
214
|
}
|
|
167
215
|
|
|
168
|
-
const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable }) => {
|
|
216
|
+
const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable, inputValues, setInputValues, staggerDelay = 0, resolveAssetUrl }) => {
|
|
169
217
|
// Variable-based conditions — hide element if condition is not met
|
|
170
218
|
if (element.conditions?.show_if) {
|
|
171
219
|
const shouldShow = evaluateCondition(element.conditions.show_if, variables);
|
|
172
220
|
if (!shouldShow) return null;
|
|
173
221
|
}
|
|
174
222
|
|
|
223
|
+
// ─── Animation State ───
|
|
224
|
+
const entranceValues = useRef(createEntranceAnimationValues()).current;
|
|
225
|
+
const interactiveValue = useRef(new Animated.Value(1)).current;
|
|
226
|
+
const [hasAnimated, setHasAnimated] = useState(false);
|
|
227
|
+
|
|
228
|
+
// Start entrance animation on mount
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (element.entrance && element.entrance.type !== 'none' && !hasAnimated) {
|
|
231
|
+
startEntranceAnimation(element.entrance, entranceValues, staggerDelay);
|
|
232
|
+
setHasAnimated(true);
|
|
233
|
+
}
|
|
234
|
+
}, [element.entrance, hasAnimated, staggerDelay]);
|
|
235
|
+
|
|
236
|
+
// Start auto-triggered interactive animations
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (element.interactive && element.interactive.trigger === 'load' && element.interactive.type !== 'none') {
|
|
239
|
+
startInteractiveAnimation(element.interactive, interactiveValue);
|
|
240
|
+
}
|
|
241
|
+
}, [element.interactive]);
|
|
242
|
+
|
|
175
243
|
const style = convertStyle(element.style || {});
|
|
176
244
|
const isToggled = toggledIds.has(element.id);
|
|
177
245
|
|
|
@@ -207,11 +275,19 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
207
275
|
if (style.width) wrapperStyle.width = style.width;
|
|
208
276
|
if (style.alignSelf) wrapperStyle.alignSelf = style.alignSelf;
|
|
209
277
|
|
|
278
|
+
const handlePress = () => {
|
|
279
|
+
// Trigger interactive animation if configured
|
|
280
|
+
if (element.interactive && element.interactive.trigger === 'tap' && element.interactive.type !== 'none') {
|
|
281
|
+
startInteractiveAnimation(element.interactive, interactiveValue);
|
|
282
|
+
}
|
|
283
|
+
onAction(element);
|
|
284
|
+
};
|
|
285
|
+
|
|
210
286
|
return (
|
|
211
287
|
<TouchableOpacity
|
|
212
288
|
key={element.id}
|
|
213
289
|
activeOpacity={0.7}
|
|
214
|
-
onPress={
|
|
290
|
+
onPress={handlePress}
|
|
215
291
|
style={wrapperStyle}
|
|
216
292
|
>
|
|
217
293
|
{content}
|
|
@@ -219,39 +295,103 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
219
295
|
);
|
|
220
296
|
};
|
|
221
297
|
|
|
222
|
-
|
|
298
|
+
// Wrapper for entrance animations
|
|
299
|
+
const wrapWithEntranceAnimation = (content: React.ReactElement): React.ReactElement => {
|
|
300
|
+
if (!element.entrance || element.entrance.type === 'none') {
|
|
301
|
+
return content;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Build animated style based on entrance type
|
|
305
|
+
const animatedStyle: any = {
|
|
306
|
+
opacity: entranceValues.opacity,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Apply scale for scale animations
|
|
310
|
+
if (element.entrance.type === 'scaleIn') {
|
|
311
|
+
animatedStyle.transform = [{ scale: entranceValues.scale }];
|
|
312
|
+
}
|
|
313
|
+
// Apply translate for slide animations
|
|
314
|
+
else if (element.entrance.type === 'slideUp' || element.entrance.type === 'slideDown') {
|
|
315
|
+
animatedStyle.transform = [{ translateY: entranceValues.translateY }];
|
|
316
|
+
}
|
|
317
|
+
else if (element.entrance.type === 'slideLeft' || element.entrance.type === 'slideRight') {
|
|
318
|
+
animatedStyle.transform = [{ translateX: entranceValues.translateX }];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return <Animated.View style={animatedStyle}>{content}</Animated.View>;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Wrapper for interactive animations
|
|
325
|
+
const wrapWithInteractiveAnimation = (content: React.ReactElement): React.ReactElement => {
|
|
326
|
+
if (!element.interactive || element.interactive.type === 'none') {
|
|
327
|
+
return content;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const animatedStyle: any = {};
|
|
331
|
+
|
|
332
|
+
switch (element.interactive.type) {
|
|
333
|
+
case 'scale':
|
|
334
|
+
case 'pulse':
|
|
335
|
+
animatedStyle.transform = [{ scale: interactiveValue }];
|
|
336
|
+
break;
|
|
337
|
+
case 'shake':
|
|
338
|
+
animatedStyle.transform = [{ translateX: interactiveValue }];
|
|
339
|
+
break;
|
|
340
|
+
case 'bounce':
|
|
341
|
+
animatedStyle.transform = [{ translateY: interactiveValue }];
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return <Animated.View style={animatedStyle}>{content}</Animated.View>;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable, inputValues, setInputValues, resolveAssetUrl };
|
|
223
349
|
|
|
224
350
|
switch (element.type) {
|
|
225
351
|
// ─── Containers ───
|
|
226
352
|
|
|
227
353
|
case 'vstack': {
|
|
354
|
+
const stagger = element.entrance?.stagger || 0;
|
|
228
355
|
const vstackContent = (
|
|
229
356
|
<View style={[style, { flexDirection: 'column' }]}>
|
|
230
|
-
{element.children?.map((child) => (
|
|
231
|
-
<RenderNode
|
|
357
|
+
{element.children?.map((child, index) => (
|
|
358
|
+
<RenderNode
|
|
359
|
+
key={child.id}
|
|
360
|
+
element={child}
|
|
361
|
+
{...childProps}
|
|
362
|
+
staggerDelay={stagger > 0 ? index * stagger : 0}
|
|
363
|
+
/>
|
|
232
364
|
))}
|
|
233
365
|
</View>
|
|
234
366
|
);
|
|
235
|
-
|
|
367
|
+
const wrapped = wrapWithAction(
|
|
236
368
|
element.style?.backgroundGradient
|
|
237
369
|
? wrapWithGradient(vstackContent, element.style, { ...style, flexDirection: 'column' })
|
|
238
370
|
: vstackContent
|
|
239
371
|
);
|
|
372
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapped));
|
|
240
373
|
}
|
|
241
374
|
|
|
242
375
|
case 'hstack': {
|
|
376
|
+
const stagger = element.entrance?.stagger || 0;
|
|
243
377
|
const hstackContent = (
|
|
244
378
|
<View style={[style, { flexDirection: 'row' }]}>
|
|
245
|
-
{element.children?.map((child) => (
|
|
246
|
-
<RenderNode
|
|
379
|
+
{element.children?.map((child, index) => (
|
|
380
|
+
<RenderNode
|
|
381
|
+
key={child.id}
|
|
382
|
+
element={child}
|
|
383
|
+
{...childProps}
|
|
384
|
+
staggerDelay={stagger > 0 ? index * stagger : 0}
|
|
385
|
+
/>
|
|
247
386
|
))}
|
|
248
387
|
</View>
|
|
249
388
|
);
|
|
250
|
-
|
|
389
|
+
const wrapped = wrapWithAction(
|
|
251
390
|
element.style?.backgroundGradient
|
|
252
391
|
? wrapWithGradient(hstackContent, element.style, { ...style, flexDirection: 'row' })
|
|
253
392
|
: hstackContent
|
|
254
393
|
);
|
|
394
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapped));
|
|
255
395
|
}
|
|
256
396
|
|
|
257
397
|
case 'zstack': {
|
|
@@ -308,11 +448,75 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
308
448
|
|
|
309
449
|
case 'text': {
|
|
310
450
|
const resolvedText = resolveTemplate(element.props?.text || '', variables);
|
|
311
|
-
|
|
451
|
+
|
|
452
|
+
// Typewriter animation support
|
|
453
|
+
const [displayedText, setDisplayedText] = useState('');
|
|
454
|
+
const [showCursor, setShowCursor] = useState(false);
|
|
455
|
+
const typewriterInterval = useRef<NodeJS.Timeout | null>(null);
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const textAnim = element.textAnimation;
|
|
459
|
+
|
|
460
|
+
if (textAnim && textAnim.type === 'typewriter') {
|
|
461
|
+
const speed = textAnim.speed || 20; // chars per second
|
|
462
|
+
const delay = textAnim.delay || 0;
|
|
463
|
+
const haptic = textAnim.haptic;
|
|
464
|
+
let currentIndex = 0;
|
|
465
|
+
|
|
466
|
+
// Show cursor if enabled
|
|
467
|
+
if (textAnim.cursor) {
|
|
468
|
+
setShowCursor(true);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Start typewriter animation after delay
|
|
472
|
+
const timeoutId = setTimeout(() => {
|
|
473
|
+
typewriterInterval.current = setInterval(() => {
|
|
474
|
+
if (currentIndex >= resolvedText.length) {
|
|
475
|
+
if (typewriterInterval.current) {
|
|
476
|
+
clearInterval(typewriterInterval.current);
|
|
477
|
+
}
|
|
478
|
+
// Hide cursor when done
|
|
479
|
+
if (textAnim.cursor) {
|
|
480
|
+
setShowCursor(false);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
setDisplayedText(resolvedText.substring(0, currentIndex + 1));
|
|
486
|
+
|
|
487
|
+
// Trigger haptic if enabled
|
|
488
|
+
if (haptic && haptic.enabled && shouldTriggerHaptic(currentIndex, haptic.frequency)) {
|
|
489
|
+
triggerHaptic(haptic.type);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
currentIndex++;
|
|
493
|
+
}, 1000 / speed);
|
|
494
|
+
}, delay);
|
|
495
|
+
|
|
496
|
+
return () => {
|
|
497
|
+
clearTimeout(timeoutId);
|
|
498
|
+
if (typewriterInterval.current) {
|
|
499
|
+
clearInterval(typewriterInterval.current);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
} else {
|
|
503
|
+
// No typewriter - show full text immediately
|
|
504
|
+
setDisplayedText(resolvedText);
|
|
505
|
+
}
|
|
506
|
+
}, [resolvedText, element.textAnimation]);
|
|
507
|
+
|
|
508
|
+
const textContent = element.textAnimation?.type === 'typewriter' ? displayedText : resolvedText;
|
|
509
|
+
|
|
510
|
+
const textElement = (
|
|
312
511
|
<Text style={style as TextStyle}>
|
|
313
|
-
{
|
|
512
|
+
{textContent}
|
|
513
|
+
{showCursor && (
|
|
514
|
+
<Text style={{ opacity: 0.5 }}>|</Text>
|
|
515
|
+
)}
|
|
314
516
|
</Text>
|
|
315
517
|
);
|
|
518
|
+
|
|
519
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapWithAction(textElement)));
|
|
316
520
|
}
|
|
317
521
|
|
|
318
522
|
case 'icon': {
|
|
@@ -358,9 +562,10 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
358
562
|
|
|
359
563
|
case 'image':
|
|
360
564
|
if (element.props?.url) {
|
|
565
|
+
const resolvedUrl = resolveAssetUrl(element.props.url);
|
|
361
566
|
return (
|
|
362
567
|
<Image
|
|
363
|
-
source={{ uri:
|
|
568
|
+
source={{ uri: resolvedUrl }}
|
|
364
569
|
style={[style as ImageStyle, { resizeMode: 'cover' }]}
|
|
365
570
|
/>
|
|
366
571
|
);
|
|
@@ -388,6 +593,11 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
388
593
|
|
|
389
594
|
case 'video':
|
|
390
595
|
// Video placeholder — actual implementation would use expo-av or react-native-video
|
|
596
|
+
// Resolve asset URL if present (for future implementation)
|
|
597
|
+
if (element.props?.url) {
|
|
598
|
+
const resolvedUrl = resolveAssetUrl(element.props.url);
|
|
599
|
+
// TODO: Implement actual video player with resolvedUrl
|
|
600
|
+
}
|
|
391
601
|
return (
|
|
392
602
|
<View
|
|
393
603
|
style={[
|
|
@@ -410,6 +620,11 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
410
620
|
|
|
411
621
|
case 'lottie':
|
|
412
622
|
// Lottie placeholder — actual implementation would use lottie-react-native
|
|
623
|
+
// Resolve asset URL if present (for future implementation)
|
|
624
|
+
if (element.props?.url) {
|
|
625
|
+
const resolvedUrl = resolveAssetUrl(element.props.url);
|
|
626
|
+
// TODO: Implement actual Lottie player with resolvedUrl
|
|
627
|
+
}
|
|
413
628
|
return (
|
|
414
629
|
<View
|
|
415
630
|
style={[
|
|
@@ -441,7 +656,8 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
441
656
|
|
|
442
657
|
// Get the variable name - use props.variable if specified, otherwise use element.id
|
|
443
658
|
const variableName = element.props?.variable || element.id;
|
|
444
|
-
|
|
659
|
+
// Use local state value, or fall back to variables, or empty string
|
|
660
|
+
const currentValue = inputValues[variableName] ?? variables[variableName] ?? '';
|
|
445
661
|
|
|
446
662
|
return (
|
|
447
663
|
<TextInput
|
|
@@ -452,9 +668,8 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
452
668
|
autoCapitalize={element.props?.type === 'email' ? 'none' : 'sentences'}
|
|
453
669
|
value={currentValue}
|
|
454
670
|
onChangeText={(text) => {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
671
|
+
// Save to local state only - don't trigger parent re-render
|
|
672
|
+
setInputValues(prev => ({ ...prev, [variableName]: text }));
|
|
458
673
|
}}
|
|
459
674
|
/>
|
|
460
675
|
);
|