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.
@@ -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={() => onAction(element)}
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
- const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable };
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 key={child.id} element={child} {...childProps} />
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
- return wrapWithAction(
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 key={child.id} element={child} {...childProps} />
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
- return wrapWithAction(
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
- return (
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
- {resolvedText}
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: element.props.url }}
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
- const currentValue = variables[variableName] || '';
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
- if (onSetVariable) {
456
- onSetVariable(variableName, text);
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
  );