react-native-lumen 1.0.0 → 1.1.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.
Files changed (51) hide show
  1. package/README.md +763 -231
  2. package/lib/module/components/TourOverlay.js +43 -3
  3. package/lib/module/components/TourOverlay.js.map +1 -1
  4. package/lib/module/components/TourProvider.js +318 -63
  5. package/lib/module/components/TourProvider.js.map +1 -1
  6. package/lib/module/components/TourTooltip.js +121 -79
  7. package/lib/module/components/TourTooltip.js.map +1 -1
  8. package/lib/module/components/TourZone.js +186 -119
  9. package/lib/module/components/TourZone.js.map +1 -1
  10. package/lib/module/constants/defaults.js +43 -0
  11. package/lib/module/constants/defaults.js.map +1 -1
  12. package/lib/module/context/TourContext.js +5 -0
  13. package/lib/module/context/TourContext.js.map +1 -0
  14. package/lib/module/hooks/useTour.js +1 -1
  15. package/lib/module/hooks/useTour.js.map +1 -1
  16. package/lib/module/hooks/useTourScrollView.js +71 -0
  17. package/lib/module/hooks/useTourScrollView.js.map +1 -0
  18. package/lib/module/index.js +6 -0
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/utils/storage.js +188 -0
  21. package/lib/module/utils/storage.js.map +1 -0
  22. package/lib/typescript/src/components/TourOverlay.d.ts.map +1 -1
  23. package/lib/typescript/src/components/TourProvider.d.ts +21 -4
  24. package/lib/typescript/src/components/TourProvider.d.ts.map +1 -1
  25. package/lib/typescript/src/components/TourTooltip.d.ts.map +1 -1
  26. package/lib/typescript/src/components/TourZone.d.ts +19 -1
  27. package/lib/typescript/src/components/TourZone.d.ts.map +1 -1
  28. package/lib/typescript/src/constants/defaults.d.ts +10 -0
  29. package/lib/typescript/src/constants/defaults.d.ts.map +1 -1
  30. package/lib/typescript/src/context/TourContext.d.ts +3 -0
  31. package/lib/typescript/src/context/TourContext.d.ts.map +1 -0
  32. package/lib/typescript/src/hooks/useTourScrollView.d.ts +65 -0
  33. package/lib/typescript/src/hooks/useTourScrollView.d.ts.map +1 -0
  34. package/lib/typescript/src/index.d.ts +4 -0
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/lib/typescript/src/types/index.d.ts +296 -1
  37. package/lib/typescript/src/types/index.d.ts.map +1 -1
  38. package/lib/typescript/src/utils/storage.d.ts +51 -0
  39. package/lib/typescript/src/utils/storage.d.ts.map +1 -0
  40. package/package.json +173 -171
  41. package/src/components/TourOverlay.tsx +45 -2
  42. package/src/components/TourProvider.tsx +409 -57
  43. package/src/components/TourTooltip.tsx +151 -74
  44. package/src/components/TourZone.tsx +238 -141
  45. package/src/constants/defaults.ts +51 -0
  46. package/src/context/TourContext.ts +4 -0
  47. package/src/hooks/useTour.ts +1 -1
  48. package/src/hooks/useTourScrollView.ts +111 -0
  49. package/src/index.tsx +27 -0
  50. package/src/types/index.ts +306 -1
  51. package/src/utils/storage.ts +226 -0
@@ -1,9 +1,9 @@
1
1
  import React, {
2
- createContext,
3
2
  useState,
4
3
  useCallback,
5
4
  useMemo,
6
5
  useRef,
6
+ useEffect,
7
7
  type ComponentType,
8
8
  } from 'react';
9
9
  import {
@@ -12,29 +12,134 @@ import {
12
12
  withTiming,
13
13
  useAnimatedRef,
14
14
  default as Animated,
15
+ type WithSpringConfig,
15
16
  } from 'react-native-reanimated';
16
- import { StyleSheet } from 'react-native';
17
+ import { StyleSheet, Dimensions } from 'react-native';
17
18
  import type {
18
19
  TourStep,
19
20
  MeasureResult,
20
21
  TourConfig,
21
22
  InternalTourContextType,
23
+ ZoneStyle,
24
+ StorageAdapter,
25
+ StepsOrder,
22
26
  } from '../types';
27
+ import { TourContext } from '../context/TourContext';
23
28
  import { TourOverlay } from './TourOverlay';
24
29
  import { TourTooltip } from './TourTooltip';
25
30
  import {
26
31
  DEFAULT_BACKDROP_OPACITY,
27
32
  DEFAULT_SPRING_CONFIG,
33
+ DEFAULT_ZONE_STYLE,
34
+ resolveZoneStyle,
28
35
  } from '../constants/defaults';
36
+ import {
37
+ detectStorage,
38
+ saveTourProgress,
39
+ loadTourProgress,
40
+ clearTourProgress,
41
+ } from '../utils/storage';
42
+
43
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
44
+
45
+ /**
46
+ * Computes the zone geometry based on element bounds and zone style.
47
+ * Handles different shapes: rounded-rect, circle, pill.
48
+ */
49
+ function computeZoneGeometry(
50
+ element: MeasureResult,
51
+ style: Required<ZoneStyle>
52
+ ): {
53
+ x: number;
54
+ y: number;
55
+ width: number;
56
+ height: number;
57
+ borderRadius: number;
58
+ } {
59
+ const {
60
+ paddingTop,
61
+ paddingRight,
62
+ paddingBottom,
63
+ paddingLeft,
64
+ shape,
65
+ borderRadius,
66
+ } = style;
67
+
68
+ let sx: number, sy: number, sw: number, sh: number, sr: number;
69
+
70
+ switch (shape) {
71
+ case 'circle': {
72
+ // Create a circular zone that encompasses the element
73
+ const cx = element.x + element.width / 2;
74
+ const cy = element.y + element.height / 2;
75
+ // Use the larger dimension to create a circle, plus padding
76
+ const radius =
77
+ Math.max(element.width, element.height) / 2 + style.padding;
78
+ sx = cx - radius;
79
+ sy = cy - radius;
80
+ sw = radius * 2;
81
+ sh = radius * 2;
82
+ sr = radius;
83
+ break;
84
+ }
85
+ case 'pill': {
86
+ // Pill shape with fully rounded ends
87
+ sx = element.x - paddingLeft;
88
+ sy = element.y - paddingTop;
89
+ sw = element.width + paddingLeft + paddingRight;
90
+ sh = element.height + paddingTop + paddingBottom;
91
+ sr = sh / 2; // Fully rounded based on height
92
+ break;
93
+ }
94
+ case 'rounded-rect':
95
+ default: {
96
+ sx = element.x - paddingLeft;
97
+ sy = element.y - paddingTop;
98
+ sw = element.width + paddingLeft + paddingRight;
99
+ sh = element.height + paddingTop + paddingBottom;
100
+ sr = borderRadius;
101
+ break;
102
+ }
103
+ }
104
+
105
+ // Clamp to screen bounds
106
+ sx = Math.max(0, Math.min(sx, SCREEN_WIDTH - sw));
107
+ sy = Math.max(0, Math.min(sy, SCREEN_HEIGHT - sh));
108
+ sw = Math.min(sw, SCREEN_WIDTH - sx);
109
+ sh = Math.min(sh, SCREEN_HEIGHT - sy);
29
110
 
30
- export const TourContext = createContext<InternalTourContextType | null>(null);
111
+ // Ensure minimum size
112
+ const minSize = 40;
113
+ sw = Math.max(sw, minSize);
114
+ sh = Math.max(sh, minSize);
115
+
116
+ return { x: sx, y: sy, width: sw, height: sh, borderRadius: sr };
117
+ }
31
118
 
32
119
  interface TourProviderProps {
33
120
  children: React.ReactNode;
34
121
  /**
35
- * Optional custom steps order. If provided, the tour will follow this array of keys.
122
+ * Optional custom steps order. Supports two formats:
123
+ *
124
+ * **Flat array** (single-screen or simple multi-screen):
125
+ * ```
126
+ * stepsOrder={['bio', 'prompt', 'poll', 'filters', 'swipeableCards']}
127
+ * ```
128
+ *
129
+ * **Screen-grouped object** (multi-screen tours):
130
+ * ```
131
+ * stepsOrder={{
132
+ * ProfileSelf: ['bio', 'prompt', 'poll'],
133
+ * HomeSwipe: ['filters'],
134
+ * SwipeableCards: ['swipeableCards'],
135
+ * }}
136
+ * ```
137
+ *
138
+ * When using the object format, steps are flattened in the order the screens appear.
139
+ * The tour automatically waits when advancing to a step on an unmounted screen,
140
+ * and resumes when that step's TourZone mounts.
36
141
  */
37
- stepsOrder?: string[];
142
+ stepsOrder?: StepsOrder;
38
143
  /**
39
144
  * Initial overlay opacity. Default 0.5
40
145
  */
@@ -55,11 +160,57 @@ export const TourProvider: React.FC<TourProviderProps> = ({
55
160
  }) => {
56
161
  const [steps, setSteps] = useState<Record<string, TourStep>>({});
57
162
  const [currentStep, setCurrentStep] = useState<string | null>(null);
163
+ const [hasSavedProgress, setHasSavedProgress] = useState(false);
58
164
 
59
165
  // ref to access latest measurements without causing re-renders
60
166
  const measurements = useRef<Record<string, MeasureResult>>({});
61
167
  const containerRef = useAnimatedRef<any>();
62
168
 
169
+ // ─── Persistence Setup ─────────────────────────────────────────────────────
170
+ const persistenceConfig = config?.persistence;
171
+ const isPersistenceEnabled = persistenceConfig?.enabled ?? false;
172
+ const tourId = persistenceConfig?.tourId ?? 'default';
173
+ const autoResume = persistenceConfig?.autoResume ?? true;
174
+ const clearOnComplete = persistenceConfig?.clearOnComplete ?? true;
175
+ const maxAge = persistenceConfig?.maxAge;
176
+
177
+ // Get storage adapter (custom or auto-detected)
178
+ const storageAdapter = useMemo<StorageAdapter | null>(() => {
179
+ if (!isPersistenceEnabled) return null;
180
+ if (persistenceConfig?.storage) return persistenceConfig.storage;
181
+ const detected = detectStorage();
182
+ return detected.adapter;
183
+ }, [isPersistenceEnabled, persistenceConfig?.storage]);
184
+
185
+ // Check for saved progress on mount
186
+ useEffect(() => {
187
+ if (!isPersistenceEnabled || !storageAdapter) {
188
+ setHasSavedProgress(false);
189
+ return;
190
+ }
191
+
192
+ const checkSavedProgress = async () => {
193
+ try {
194
+ const savedProgress = await loadTourProgress(storageAdapter, tourId);
195
+ if (savedProgress) {
196
+ // Check if progress is expired
197
+ if (maxAge && Date.now() - savedProgress.timestamp > maxAge) {
198
+ await clearTourProgress(storageAdapter, tourId);
199
+ setHasSavedProgress(false);
200
+ } else {
201
+ setHasSavedProgress(true);
202
+ }
203
+ } else {
204
+ setHasSavedProgress(false);
205
+ }
206
+ } catch {
207
+ setHasSavedProgress(false);
208
+ }
209
+ };
210
+
211
+ checkSavedProgress();
212
+ }, [isPersistenceEnabled, storageAdapter, tourId, maxAge]);
213
+
63
214
  // --- Shared Values for Animations (Zero Bridge Crossing) ---
64
215
  // Initialize off-screen or 0
65
216
  const targetX = useSharedValue(0);
@@ -68,6 +219,41 @@ export const TourProvider: React.FC<TourProviderProps> = ({
68
219
  const targetHeight = useSharedValue(0);
69
220
  const targetRadius = useSharedValue(10); // Default border radius
70
221
  const opacity = useSharedValue(0); // 0 = hidden, 1 = visible
222
+ const zoneBorderWidth = useSharedValue(DEFAULT_ZONE_STYLE.borderWidth);
223
+
224
+ // Track current step's resolved zone style
225
+ const currentZoneStyle = useMemo<ZoneStyle | null>(() => {
226
+ if (!currentStep) return null;
227
+ const step = steps[currentStep];
228
+ if (!step) return null;
229
+ return resolveZoneStyle(config?.zoneStyle, step.zoneStyle);
230
+ }, [currentStep, steps, config?.zoneStyle]);
231
+
232
+ // Helper to get spring config for a step (supports per-step overrides)
233
+ const getSpringConfigForStep = useCallback(
234
+ (stepKey: string): WithSpringConfig => {
235
+ const step = steps[stepKey];
236
+ const stepStyle = step?.zoneStyle;
237
+ const baseConfig = config?.springConfig ?? DEFAULT_SPRING_CONFIG;
238
+
239
+ // Allow per-step spring overrides
240
+ if (
241
+ stepStyle?.springDamping !== undefined ||
242
+ stepStyle?.springStiffness !== undefined
243
+ ) {
244
+ return {
245
+ damping: stepStyle.springDamping ?? baseConfig.damping,
246
+ stiffness: stepStyle.springStiffness ?? baseConfig.stiffness,
247
+ mass: baseConfig.mass,
248
+ overshootClamping: baseConfig.overshootClamping,
249
+ restDisplacementThreshold: baseConfig.restDisplacementThreshold,
250
+ restSpeedThreshold: baseConfig.restSpeedThreshold,
251
+ };
252
+ }
253
+ return baseConfig;
254
+ },
255
+ [steps, config?.springConfig]
256
+ );
71
257
 
72
258
  // Helper to animate to a specific step's layout
73
259
  const animateToStep = useCallback(
@@ -93,19 +279,25 @@ export const TourProvider: React.FC<TourProviderProps> = ({
93
279
  return;
94
280
  }
95
281
 
96
- const springConfig = config?.springConfig ?? DEFAULT_SPRING_CONFIG;
282
+ const step = steps[stepKey];
283
+ const resolvedStyle = resolveZoneStyle(
284
+ config?.zoneStyle,
285
+ step?.zoneStyle
286
+ );
287
+ const springConfig = getSpringConfigForStep(stepKey);
97
288
 
98
- targetX.value = withSpring(measure.x, springConfig);
99
- targetY.value = withSpring(measure.y, springConfig);
100
- targetWidth.value = withSpring(measure.width, springConfig);
101
- targetHeight.value = withSpring(measure.height, springConfig);
289
+ // Compute zone geometry based on style (handles shapes and padding)
290
+ const geo = computeZoneGeometry(measure, resolvedStyle);
102
291
 
103
- // If measure result has radius or step meta has radius, use it.
104
- // For now defaulting to 10 or meta reading if we passed it back in measure?
105
- // Let's assume meta is in steps state.
106
- const step = steps[stepKey];
107
- const radius = step?.meta?.borderRadius ?? 10;
108
- targetRadius.value = withSpring(radius, springConfig);
292
+ targetX.value = withSpring(geo.x, springConfig);
293
+ targetY.value = withSpring(geo.y, springConfig);
294
+ targetWidth.value = withSpring(geo.width, springConfig);
295
+ targetHeight.value = withSpring(geo.height, springConfig);
296
+ targetRadius.value = withSpring(geo.borderRadius, springConfig);
297
+ zoneBorderWidth.value = withSpring(
298
+ resolvedStyle.borderWidth,
299
+ springConfig
300
+ );
109
301
 
110
302
  // Ensure overlay is visible
111
303
  opacity.value = withTiming(backdropOpacity, { duration: 300 });
@@ -120,14 +312,22 @@ export const TourProvider: React.FC<TourProviderProps> = ({
120
312
  targetWidth,
121
313
  targetHeight,
122
314
  targetRadius,
315
+ zoneBorderWidth,
123
316
  opacity,
124
- config?.springConfig,
317
+ getSpringConfigForStep,
125
318
  steps,
319
+ config?.zoneStyle,
126
320
  ]
127
321
  );
128
322
 
129
323
  const registerStep = useCallback((step: TourStep) => {
130
324
  setSteps((prev) => ({ ...prev, [step.key]: step }));
325
+ // If this step was pending (waiting for cross-screen mount), activate it
326
+ if (pendingStepRef.current === step.key) {
327
+ pendingStepRef.current = null;
328
+ setCurrentStep(step.key);
329
+ // Overlay opacity will be set by updateStepLayout when measurement arrives
330
+ }
131
331
  }, []);
132
332
 
133
333
  const unregisterStep = useCallback((key: string) => {
@@ -166,17 +366,25 @@ export const TourProvider: React.FC<TourProviderProps> = ({
166
366
  measurements.current[key] = measure;
167
367
  // If this step is currently active (e.g. scroll happened or resize), update shared values on the fly
168
368
  if (currentStep === key) {
169
- const springConfig = config?.springConfig ?? DEFAULT_SPRING_CONFIG;
369
+ const step = steps[key];
370
+ const resolvedStyle = resolveZoneStyle(
371
+ config?.zoneStyle,
372
+ step?.zoneStyle
373
+ );
374
+ const springConfig = getSpringConfigForStep(key);
170
375
 
171
- targetX.value = withSpring(measure.x, springConfig);
172
- targetY.value = withSpring(measure.y, springConfig);
173
- targetWidth.value = withSpring(measure.width, springConfig);
174
- targetHeight.value = withSpring(measure.height, springConfig);
376
+ // Compute zone geometry based on style
377
+ const geo = computeZoneGeometry(measure, resolvedStyle);
175
378
 
176
- // Update radius if available
177
- const step = steps[key];
178
- const radius = step?.meta?.borderRadius ?? 10;
179
- targetRadius.value = withSpring(radius, springConfig);
379
+ targetX.value = withSpring(geo.x, springConfig);
380
+ targetY.value = withSpring(geo.y, springConfig);
381
+ targetWidth.value = withSpring(geo.width, springConfig);
382
+ targetHeight.value = withSpring(geo.height, springConfig);
383
+ targetRadius.value = withSpring(geo.borderRadius, springConfig);
384
+ zoneBorderWidth.value = withSpring(
385
+ resolvedStyle.borderWidth,
386
+ springConfig
387
+ );
180
388
 
181
389
  // Ensure overlay is visible (fixes race condition where start() was called before measure)
182
390
  opacity.value = withTiming(backdropOpacity, { duration: 300 });
@@ -189,15 +397,25 @@ export const TourProvider: React.FC<TourProviderProps> = ({
189
397
  targetWidth,
190
398
  targetHeight,
191
399
  targetRadius,
400
+ zoneBorderWidth,
192
401
  opacity,
193
402
  backdropOpacity,
194
- config?.springConfig,
403
+ getSpringConfigForStep,
404
+ config?.zoneStyle,
195
405
  steps,
196
406
  ]
197
407
  );
198
408
 
409
+ // Flatten stepsOrder (supports both string[] and Record<string, string[]>)
410
+ const flatStepsOrder = useMemo<string[] | undefined>(() => {
411
+ if (!initialStepsOrder) return undefined;
412
+ if (Array.isArray(initialStepsOrder)) return initialStepsOrder;
413
+ // Object format: flatten values in key order
414
+ return Object.values(initialStepsOrder).flat();
415
+ }, [initialStepsOrder]);
416
+
199
417
  const getOrderedSteps = useCallback(() => {
200
- if (initialStepsOrder) return initialStepsOrder;
418
+ if (flatStepsOrder) return flatStepsOrder;
201
419
  // If order property exists on steps, sort by it.
202
420
  const stepKeys = Object.keys(steps);
203
421
  if (stepKeys.length > 0) {
@@ -212,65 +430,186 @@ export const TourProvider: React.FC<TourProviderProps> = ({
212
430
  }
213
431
  }
214
432
  return stepKeys;
215
- }, [initialStepsOrder, steps]);
433
+ }, [flatStepsOrder, steps]);
434
+
435
+ // Pending step for cross-screen navigation
436
+ // When next/prev targets a step that isn't mounted yet, we store it here
437
+ // and resume when that step's TourZone registers.
438
+ const pendingStepRef = useRef<string | null>(null);
439
+
440
+ // Save progress when step changes
441
+ useEffect(() => {
442
+ if (!isPersistenceEnabled || !storageAdapter || !currentStep) return;
443
+
444
+ const ordered = getOrderedSteps();
445
+ const stepIndex = ordered.indexOf(currentStep);
446
+
447
+ if (stepIndex >= 0) {
448
+ saveTourProgress(storageAdapter, tourId, currentStep, stepIndex).catch(
449
+ () => {
450
+ // Silently ignore save errors
451
+ }
452
+ );
453
+ }
454
+ }, [
455
+ currentStep,
456
+ isPersistenceEnabled,
457
+ storageAdapter,
458
+ tourId,
459
+ getOrderedSteps,
460
+ ]);
216
461
 
217
462
  const start = useCallback(
218
- (stepKey?: string) => {
463
+ async (stepKey?: string) => {
219
464
  const ordered = getOrderedSteps();
220
- const firstStep = stepKey || ordered[0];
465
+
466
+ let targetStep = stepKey;
467
+
468
+ // If no specific step and autoResume is enabled, try to restore from storage
469
+ if (!targetStep && isPersistenceEnabled && storageAdapter && autoResume) {
470
+ try {
471
+ const savedProgress = await loadTourProgress(storageAdapter, tourId);
472
+ if (savedProgress) {
473
+ // Check if progress is expired
474
+ if (maxAge && Date.now() - savedProgress.timestamp > maxAge) {
475
+ await clearTourProgress(storageAdapter, tourId);
476
+ setHasSavedProgress(false);
477
+ } else if (ordered.includes(savedProgress.currentStepKey)) {
478
+ // Verify the saved step still exists in order
479
+ targetStep = savedProgress.currentStepKey;
480
+ }
481
+ }
482
+ } catch {
483
+ // Ignore load errors, start from beginning
484
+ }
485
+ }
486
+
487
+ const firstStep = targetStep || ordered[0];
221
488
  if (firstStep) {
222
- setCurrentStep(firstStep);
223
- // We need to wait for layout if it's not ready?
224
- // Assuming layout is ready since components are mounted.
225
- // But if we start immediately on mount, might be tricky.
226
- // For now assume standard flow.
227
- // requestAnimationFrame to ensure state update propagates if needed,
228
- // but simple call is usually fine.
229
- setTimeout(() => animateToStep(firstStep), 0);
489
+ // Check if the target step is registered (mounted)
490
+ if (steps[firstStep]) {
491
+ setCurrentStep(firstStep);
492
+ setTimeout(() => animateToStep(firstStep), 0);
493
+ } else {
494
+ // Step not mounted yet (on a different screen) - set as pending
495
+ pendingStepRef.current = firstStep;
496
+ // Don't set currentStep or opacity - wait for TourZone to mount
497
+ }
230
498
  }
231
499
  },
232
- [getOrderedSteps, animateToStep]
500
+ [
501
+ getOrderedSteps,
502
+ animateToStep,
503
+ steps,
504
+ isPersistenceEnabled,
505
+ storageAdapter,
506
+ autoResume,
507
+ tourId,
508
+ maxAge,
509
+ ]
233
510
  );
234
511
 
235
512
  const stop = useCallback(() => {
236
513
  setCurrentStep(null);
237
514
  opacity.value = withTiming(0, { duration: 300 });
515
+ // Note: We do NOT clear progress on stop - only on complete or explicit clearProgress
238
516
  }, [opacity]);
239
517
 
518
+ // Clear progress helper
519
+ const clearProgress = useCallback(async () => {
520
+ if (!isPersistenceEnabled || !storageAdapter) return;
521
+ try {
522
+ await clearTourProgress(storageAdapter, tourId);
523
+ setHasSavedProgress(false);
524
+ } catch {
525
+ // Silently ignore clear errors
526
+ }
527
+ }, [isPersistenceEnabled, storageAdapter, tourId]);
528
+
240
529
  const next = useCallback(() => {
241
530
  if (!currentStep) return;
531
+
532
+ // Block navigation if current step has completed === false
533
+ const currentStepData = steps[currentStep];
534
+ if (currentStepData?.completed === false) {
535
+ return;
536
+ }
537
+
242
538
  const ordered = getOrderedSteps();
243
539
  const currentIndex = ordered.indexOf(currentStep);
244
540
  if (currentIndex < ordered.length - 1) {
245
- const nextStep = ordered[currentIndex + 1];
246
- if (nextStep) {
247
- setCurrentStep(nextStep);
248
- // Don't call animateToStep here - it uses cached measurements that may be stale
249
- // after scroll. The useFrameCallback in TourZone will handle position tracking
250
- // using measure() with correct screen coordinates (pageX/pageY).
251
- // Just ensure the overlay is visible.
252
- opacity.value = withTiming(backdropOpacity, { duration: 300 });
541
+ const nextStepKey = ordered[currentIndex + 1];
542
+ if (nextStepKey) {
543
+ // Check if the next step is registered (mounted)
544
+ if (steps[nextStepKey]) {
545
+ setCurrentStep(nextStepKey);
546
+ // Don't call animateToStep here - it uses cached measurements that may be stale
547
+ // after scroll. The useFrameCallback in TourZone will handle position tracking
548
+ // using measure() with correct screen coordinates (pageX/pageY).
549
+ // Just ensure the overlay is visible.
550
+ opacity.value = withTiming(backdropOpacity, { duration: 300 });
551
+ } else {
552
+ // Step not mounted yet (on a different screen) - set as pending
553
+ pendingStepRef.current = nextStepKey;
554
+ setCurrentStep(null);
555
+ opacity.value = withTiming(0, { duration: 300 });
556
+ // Persist pending step so it can be resumed
557
+ if (isPersistenceEnabled && storageAdapter) {
558
+ const stepIndex = ordered.indexOf(nextStepKey);
559
+ saveTourProgress(
560
+ storageAdapter,
561
+ tourId,
562
+ nextStepKey,
563
+ stepIndex
564
+ ).catch(() => {});
565
+ }
566
+ }
253
567
  } else {
254
568
  stop();
255
569
  }
256
570
  } else {
257
- stop(); // End of tour
571
+ // End of tour - clear progress if configured
572
+ if (isPersistenceEnabled && clearOnComplete && storageAdapter) {
573
+ clearTourProgress(storageAdapter, tourId)
574
+ .then(() => setHasSavedProgress(false))
575
+ .catch(() => {});
576
+ }
577
+ stop();
258
578
  }
259
- }, [currentStep, getOrderedSteps, stop, opacity, backdropOpacity]);
579
+ }, [
580
+ currentStep,
581
+ steps,
582
+ getOrderedSteps,
583
+ stop,
584
+ opacity,
585
+ backdropOpacity,
586
+ isPersistenceEnabled,
587
+ clearOnComplete,
588
+ storageAdapter,
589
+ tourId,
590
+ ]);
260
591
 
261
592
  const prev = useCallback(() => {
262
593
  if (!currentStep) return;
263
594
  const ordered = getOrderedSteps();
264
595
  const currentIndex = ordered.indexOf(currentStep);
265
596
  if (currentIndex > 0) {
266
- const prevStep = ordered[currentIndex - 1];
267
- if (prevStep) {
268
- setCurrentStep(prevStep);
269
- // Don't call animateToStep - let useFrameCallback handle position tracking
270
- opacity.value = withTiming(backdropOpacity, { duration: 300 });
597
+ const prevStepKey = ordered[currentIndex - 1];
598
+ if (prevStepKey) {
599
+ // Check if the previous step is registered (mounted)
600
+ if (steps[prevStepKey]) {
601
+ setCurrentStep(prevStepKey);
602
+ // Don't call animateToStep - let useFrameCallback handle position tracking
603
+ opacity.value = withTiming(backdropOpacity, { duration: 300 });
604
+ } else {
605
+ // Step not mounted (on a different screen) - set as pending
606
+ pendingStepRef.current = prevStepKey;
607
+ setCurrentStep(null);
608
+ opacity.value = withTiming(0, { duration: 300 });
609
+ }
271
610
  }
272
611
  }
273
- }, [currentStep, getOrderedSteps, opacity, backdropOpacity]);
612
+ }, [currentStep, steps, getOrderedSteps, opacity, backdropOpacity]);
274
613
 
275
614
  const scrollViewRef = useAnimatedRef<any>();
276
615
 
@@ -294,6 +633,9 @@ export const TourProvider: React.FC<TourProviderProps> = ({
294
633
  // For now, let's just return the `scrollViewRef` we created.
295
634
  }, []);
296
635
 
636
+ // Expose ordered step keys for tooltip and external use
637
+ const orderedStepKeys = useMemo(() => getOrderedSteps(), [getOrderedSteps]);
638
+
297
639
  const value = useMemo<InternalTourContextType>(
298
640
  () => ({
299
641
  start,
@@ -310,11 +652,16 @@ export const TourProvider: React.FC<TourProviderProps> = ({
310
652
  targetHeight,
311
653
  targetRadius,
312
654
  opacity,
655
+ zoneBorderWidth,
313
656
  steps,
314
657
  config,
315
658
  containerRef,
316
659
  scrollViewRef,
317
660
  setScrollViewRef,
661
+ currentZoneStyle,
662
+ clearProgress,
663
+ hasSavedProgress,
664
+ orderedStepKeys,
318
665
  }),
319
666
  [
320
667
  start,
@@ -331,11 +678,16 @@ export const TourProvider: React.FC<TourProviderProps> = ({
331
678
  targetHeight,
332
679
  targetRadius,
333
680
  opacity,
681
+ zoneBorderWidth,
334
682
  steps,
335
683
  config,
336
- // containerRef is stable
684
+ containerRef,
337
685
  scrollViewRef,
338
686
  setScrollViewRef,
687
+ currentZoneStyle,
688
+ clearProgress,
689
+ hasSavedProgress,
690
+ orderedStepKeys,
339
691
  ]
340
692
  );
341
693