react-native-lumen 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/lib/module/components/TourOverlay.js +9 -49
- package/lib/module/components/TourOverlay.js.map +1 -1
- package/lib/module/components/TourProvider.js +37 -29
- package/lib/module/components/TourProvider.js.map +1 -1
- package/lib/module/components/TourZone.js +66 -29
- package/lib/module/components/TourZone.js.map +1 -1
- package/lib/module/hooks/useTourScrollView.js +6 -3
- package/lib/module/hooks/useTourScrollView.js.map +1 -1
- package/lib/typescript/src/components/TourOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/TourProvider.d.ts.map +1 -1
- package/lib/typescript/src/components/TourZone.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useTourScrollView.d.ts +12 -1
- package/lib/typescript/src/hooks/useTourScrollView.d.ts.map +1 -1
- package/lib/typescript/src/types/index.d.ts +20 -0
- package/lib/typescript/src/types/index.d.ts.map +1 -1
- package/package.json +1 -5
- package/src/components/TourOverlay.tsx +0 -196
- package/src/components/TourProvider.tsx +0 -713
- package/src/components/TourTooltip.tsx +0 -329
- package/src/components/TourZone.tsx +0 -469
- package/src/constants/animations.ts +0 -71
- package/src/constants/defaults.ts +0 -66
- package/src/context/TourContext.ts +0 -4
- package/src/hooks/useTour.ts +0 -10
- package/src/hooks/useTourScrollView.ts +0 -111
- package/src/index.tsx +0 -35
- package/src/types/index.ts +0 -447
- package/src/utils/storage.ts +0 -226
|
@@ -1,713 +0,0 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
useState,
|
|
3
|
-
useCallback,
|
|
4
|
-
useMemo,
|
|
5
|
-
useRef,
|
|
6
|
-
useEffect,
|
|
7
|
-
type ComponentType,
|
|
8
|
-
} from 'react';
|
|
9
|
-
import {
|
|
10
|
-
useSharedValue,
|
|
11
|
-
withSpring,
|
|
12
|
-
withTiming,
|
|
13
|
-
useAnimatedRef,
|
|
14
|
-
default as Animated,
|
|
15
|
-
type WithSpringConfig,
|
|
16
|
-
} from 'react-native-reanimated';
|
|
17
|
-
import { StyleSheet, Dimensions } from 'react-native';
|
|
18
|
-
import type {
|
|
19
|
-
TourStep,
|
|
20
|
-
MeasureResult,
|
|
21
|
-
TourConfig,
|
|
22
|
-
InternalTourContextType,
|
|
23
|
-
ZoneStyle,
|
|
24
|
-
StorageAdapter,
|
|
25
|
-
StepsOrder,
|
|
26
|
-
} from '../types';
|
|
27
|
-
import { TourContext } from '../context/TourContext';
|
|
28
|
-
import { TourOverlay } from './TourOverlay';
|
|
29
|
-
import { TourTooltip } from './TourTooltip';
|
|
30
|
-
import {
|
|
31
|
-
DEFAULT_BACKDROP_OPACITY,
|
|
32
|
-
DEFAULT_SPRING_CONFIG,
|
|
33
|
-
DEFAULT_ZONE_STYLE,
|
|
34
|
-
resolveZoneStyle,
|
|
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);
|
|
110
|
-
|
|
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
|
-
}
|
|
118
|
-
|
|
119
|
-
interface TourProviderProps {
|
|
120
|
-
children: React.ReactNode;
|
|
121
|
-
/**
|
|
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.
|
|
141
|
-
*/
|
|
142
|
-
stepsOrder?: StepsOrder;
|
|
143
|
-
/**
|
|
144
|
-
* Initial overlay opacity. Default 0.5
|
|
145
|
-
*/
|
|
146
|
-
backdropOpacity?: number;
|
|
147
|
-
/**
|
|
148
|
-
* Global configuration for the tour.
|
|
149
|
-
*/
|
|
150
|
-
config?: TourConfig;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const AnimatedView = Animated.View as unknown as ComponentType<any>;
|
|
154
|
-
|
|
155
|
-
export const TourProvider: React.FC<TourProviderProps> = ({
|
|
156
|
-
children,
|
|
157
|
-
stepsOrder: initialStepsOrder,
|
|
158
|
-
backdropOpacity = DEFAULT_BACKDROP_OPACITY,
|
|
159
|
-
config,
|
|
160
|
-
}) => {
|
|
161
|
-
const [steps, setSteps] = useState<Record<string, TourStep>>({});
|
|
162
|
-
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
|
163
|
-
const [hasSavedProgress, setHasSavedProgress] = useState(false);
|
|
164
|
-
|
|
165
|
-
// ref to access latest measurements without causing re-renders
|
|
166
|
-
const measurements = useRef<Record<string, MeasureResult>>({});
|
|
167
|
-
const containerRef = useAnimatedRef<any>();
|
|
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
|
-
|
|
214
|
-
// --- Shared Values for Animations (Zero Bridge Crossing) ---
|
|
215
|
-
// Initialize off-screen or 0
|
|
216
|
-
const targetX = useSharedValue(0);
|
|
217
|
-
const targetY = useSharedValue(0);
|
|
218
|
-
const targetWidth = useSharedValue(0);
|
|
219
|
-
const targetHeight = useSharedValue(0);
|
|
220
|
-
const targetRadius = useSharedValue(10); // Default border radius
|
|
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
|
-
);
|
|
257
|
-
|
|
258
|
-
// Helper to animate to a specific step's layout
|
|
259
|
-
const animateToStep = useCallback(
|
|
260
|
-
(stepKey: string) => {
|
|
261
|
-
const measure = measurements.current[stepKey];
|
|
262
|
-
if (measure) {
|
|
263
|
-
// Validate measurements before animating
|
|
264
|
-
if (
|
|
265
|
-
!measure.width ||
|
|
266
|
-
!measure.height ||
|
|
267
|
-
measure.width <= 0 ||
|
|
268
|
-
measure.height <= 0 ||
|
|
269
|
-
isNaN(measure.x) ||
|
|
270
|
-
isNaN(measure.y) ||
|
|
271
|
-
isNaN(measure.width) ||
|
|
272
|
-
isNaN(measure.height)
|
|
273
|
-
) {
|
|
274
|
-
console.warn(
|
|
275
|
-
'[TourProvider] Invalid measurements for step:',
|
|
276
|
-
stepKey,
|
|
277
|
-
measure
|
|
278
|
-
);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const step = steps[stepKey];
|
|
283
|
-
const resolvedStyle = resolveZoneStyle(
|
|
284
|
-
config?.zoneStyle,
|
|
285
|
-
step?.zoneStyle
|
|
286
|
-
);
|
|
287
|
-
const springConfig = getSpringConfigForStep(stepKey);
|
|
288
|
-
|
|
289
|
-
// Compute zone geometry based on style (handles shapes and padding)
|
|
290
|
-
const geo = computeZoneGeometry(measure, resolvedStyle);
|
|
291
|
-
|
|
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
|
-
);
|
|
301
|
-
|
|
302
|
-
// Ensure overlay is visible
|
|
303
|
-
opacity.value = withTiming(backdropOpacity, { duration: 300 });
|
|
304
|
-
} else {
|
|
305
|
-
console.warn('[TourProvider] No measurements found for step:', stepKey);
|
|
306
|
-
}
|
|
307
|
-
},
|
|
308
|
-
[
|
|
309
|
-
backdropOpacity,
|
|
310
|
-
targetX,
|
|
311
|
-
targetY,
|
|
312
|
-
targetWidth,
|
|
313
|
-
targetHeight,
|
|
314
|
-
targetRadius,
|
|
315
|
-
zoneBorderWidth,
|
|
316
|
-
opacity,
|
|
317
|
-
getSpringConfigForStep,
|
|
318
|
-
steps,
|
|
319
|
-
config?.zoneStyle,
|
|
320
|
-
]
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
const registerStep = useCallback((step: TourStep) => {
|
|
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
|
-
}
|
|
331
|
-
}, []);
|
|
332
|
-
|
|
333
|
-
const unregisterStep = useCallback((key: string) => {
|
|
334
|
-
setSteps((prev) => {
|
|
335
|
-
const newSteps = { ...prev };
|
|
336
|
-
delete newSteps[key];
|
|
337
|
-
return newSteps;
|
|
338
|
-
});
|
|
339
|
-
}, []);
|
|
340
|
-
|
|
341
|
-
const updateStepLayout = useCallback(
|
|
342
|
-
(key: string, measure: MeasureResult) => {
|
|
343
|
-
// Validate measurements before storing
|
|
344
|
-
if (
|
|
345
|
-
!measure.width ||
|
|
346
|
-
!measure.height ||
|
|
347
|
-
measure.width <= 0 ||
|
|
348
|
-
measure.height <= 0 ||
|
|
349
|
-
isNaN(measure.x) ||
|
|
350
|
-
isNaN(measure.y) ||
|
|
351
|
-
isNaN(measure.width) ||
|
|
352
|
-
isNaN(measure.height) ||
|
|
353
|
-
!isFinite(measure.x) ||
|
|
354
|
-
!isFinite(measure.y) ||
|
|
355
|
-
!isFinite(measure.width) ||
|
|
356
|
-
!isFinite(measure.height)
|
|
357
|
-
) {
|
|
358
|
-
console.warn(
|
|
359
|
-
'[TourProvider] Invalid measurement update for step:',
|
|
360
|
-
key,
|
|
361
|
-
measure
|
|
362
|
-
);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
measurements.current[key] = measure;
|
|
367
|
-
// If this step is currently active (e.g. scroll happened or resize), update shared values on the fly
|
|
368
|
-
if (currentStep === key) {
|
|
369
|
-
const step = steps[key];
|
|
370
|
-
const resolvedStyle = resolveZoneStyle(
|
|
371
|
-
config?.zoneStyle,
|
|
372
|
-
step?.zoneStyle
|
|
373
|
-
);
|
|
374
|
-
const springConfig = getSpringConfigForStep(key);
|
|
375
|
-
|
|
376
|
-
// Compute zone geometry based on style
|
|
377
|
-
const geo = computeZoneGeometry(measure, resolvedStyle);
|
|
378
|
-
|
|
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
|
-
);
|
|
388
|
-
|
|
389
|
-
// Ensure overlay is visible (fixes race condition where start() was called before measure)
|
|
390
|
-
opacity.value = withTiming(backdropOpacity, { duration: 300 });
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
[
|
|
394
|
-
currentStep,
|
|
395
|
-
targetX,
|
|
396
|
-
targetY,
|
|
397
|
-
targetWidth,
|
|
398
|
-
targetHeight,
|
|
399
|
-
targetRadius,
|
|
400
|
-
zoneBorderWidth,
|
|
401
|
-
opacity,
|
|
402
|
-
backdropOpacity,
|
|
403
|
-
getSpringConfigForStep,
|
|
404
|
-
config?.zoneStyle,
|
|
405
|
-
steps,
|
|
406
|
-
]
|
|
407
|
-
);
|
|
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
|
-
|
|
417
|
-
const getOrderedSteps = useCallback(() => {
|
|
418
|
-
if (flatStepsOrder) return flatStepsOrder;
|
|
419
|
-
// If order property exists on steps, sort by it.
|
|
420
|
-
const stepKeys = Object.keys(steps);
|
|
421
|
-
if (stepKeys.length > 0) {
|
|
422
|
-
// Check if any step has order
|
|
423
|
-
const hasOrder = stepKeys.some(
|
|
424
|
-
(key) => typeof steps[key]?.order === 'number'
|
|
425
|
-
);
|
|
426
|
-
if (hasOrder) {
|
|
427
|
-
return stepKeys.sort(
|
|
428
|
-
(a, b) => (steps[a]?.order ?? 0) - (steps[b]?.order ?? 0)
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return stepKeys;
|
|
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
|
-
]);
|
|
461
|
-
|
|
462
|
-
const start = useCallback(
|
|
463
|
-
async (stepKey?: string) => {
|
|
464
|
-
const ordered = getOrderedSteps();
|
|
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];
|
|
488
|
-
if (firstStep) {
|
|
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
|
-
}
|
|
498
|
-
}
|
|
499
|
-
},
|
|
500
|
-
[
|
|
501
|
-
getOrderedSteps,
|
|
502
|
-
animateToStep,
|
|
503
|
-
steps,
|
|
504
|
-
isPersistenceEnabled,
|
|
505
|
-
storageAdapter,
|
|
506
|
-
autoResume,
|
|
507
|
-
tourId,
|
|
508
|
-
maxAge,
|
|
509
|
-
]
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
const stop = useCallback(() => {
|
|
513
|
-
setCurrentStep(null);
|
|
514
|
-
opacity.value = withTiming(0, { duration: 300 });
|
|
515
|
-
// Note: We do NOT clear progress on stop - only on complete or explicit clearProgress
|
|
516
|
-
}, [opacity]);
|
|
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
|
-
|
|
529
|
-
const next = useCallback(() => {
|
|
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
|
-
|
|
538
|
-
const ordered = getOrderedSteps();
|
|
539
|
-
const currentIndex = ordered.indexOf(currentStep);
|
|
540
|
-
if (currentIndex < ordered.length - 1) {
|
|
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
|
-
}
|
|
567
|
-
} else {
|
|
568
|
-
stop();
|
|
569
|
-
}
|
|
570
|
-
} else {
|
|
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();
|
|
578
|
-
}
|
|
579
|
-
}, [
|
|
580
|
-
currentStep,
|
|
581
|
-
steps,
|
|
582
|
-
getOrderedSteps,
|
|
583
|
-
stop,
|
|
584
|
-
opacity,
|
|
585
|
-
backdropOpacity,
|
|
586
|
-
isPersistenceEnabled,
|
|
587
|
-
clearOnComplete,
|
|
588
|
-
storageAdapter,
|
|
589
|
-
tourId,
|
|
590
|
-
]);
|
|
591
|
-
|
|
592
|
-
const prev = useCallback(() => {
|
|
593
|
-
if (!currentStep) return;
|
|
594
|
-
const ordered = getOrderedSteps();
|
|
595
|
-
const currentIndex = ordered.indexOf(currentStep);
|
|
596
|
-
if (currentIndex > 0) {
|
|
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
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
}, [currentStep, steps, getOrderedSteps, opacity, backdropOpacity]);
|
|
613
|
-
|
|
614
|
-
const scrollViewRef = useAnimatedRef<any>();
|
|
615
|
-
|
|
616
|
-
const setScrollViewRef = useCallback((_ref: any) => {
|
|
617
|
-
// If user passes a ref, we might want to sync it?
|
|
618
|
-
// Or we just provide this function for them to give us the ref.
|
|
619
|
-
// With useAnimatedRef, we can assign it if it's a function or object?
|
|
620
|
-
// Actually, safest is to let them assign our ref to their component.
|
|
621
|
-
// But they might have their own ref.
|
|
622
|
-
// Let's assume they call this with their ref.
|
|
623
|
-
// BUT useAnimatedRef cannot easily accept an external ref object to "become".
|
|
624
|
-
// Pattern: They should use the ref we give them, OR we wrap their component?
|
|
625
|
-
// Simpler: We just expose 'scrollViewRef' from context, and they attach it.
|
|
626
|
-
// So 'setScrollViewRef' might be redundant if we just say "here is the ref, use it".
|
|
627
|
-
// But if they have their own, they can't usage two refs easily without merging.
|
|
628
|
-
// Let's stick to exposing `scrollViewRef` from context that they MUST use.
|
|
629
|
-
// But wait, the interface says `setScrollViewRef`.
|
|
630
|
-
// Let's keep `setScrollViewRef` as a no-op or a way to manually set it if needed (not RecAnimated friendly).
|
|
631
|
-
// Actually, let's just expose `scrollViewRef` and `registerScrollView` which essentially does nothing if we expect them to use the ref object.
|
|
632
|
-
// Let's make `setScrollViewRef` actually do something if possible, or just document "Use exposed scrollViewRef".
|
|
633
|
-
// For now, let's just return the `scrollViewRef` we created.
|
|
634
|
-
}, []);
|
|
635
|
-
|
|
636
|
-
// Expose ordered step keys for tooltip and external use
|
|
637
|
-
const orderedStepKeys = useMemo(() => getOrderedSteps(), [getOrderedSteps]);
|
|
638
|
-
|
|
639
|
-
const value = useMemo<InternalTourContextType>(
|
|
640
|
-
() => ({
|
|
641
|
-
start,
|
|
642
|
-
stop,
|
|
643
|
-
next,
|
|
644
|
-
prev,
|
|
645
|
-
registerStep,
|
|
646
|
-
unregisterStep,
|
|
647
|
-
updateStepLayout,
|
|
648
|
-
currentStep,
|
|
649
|
-
targetX,
|
|
650
|
-
targetY,
|
|
651
|
-
targetWidth,
|
|
652
|
-
targetHeight,
|
|
653
|
-
targetRadius,
|
|
654
|
-
opacity,
|
|
655
|
-
zoneBorderWidth,
|
|
656
|
-
steps,
|
|
657
|
-
config,
|
|
658
|
-
containerRef,
|
|
659
|
-
scrollViewRef,
|
|
660
|
-
setScrollViewRef,
|
|
661
|
-
currentZoneStyle,
|
|
662
|
-
clearProgress,
|
|
663
|
-
hasSavedProgress,
|
|
664
|
-
orderedStepKeys,
|
|
665
|
-
}),
|
|
666
|
-
[
|
|
667
|
-
start,
|
|
668
|
-
stop,
|
|
669
|
-
next,
|
|
670
|
-
prev,
|
|
671
|
-
registerStep,
|
|
672
|
-
unregisterStep,
|
|
673
|
-
updateStepLayout,
|
|
674
|
-
currentStep,
|
|
675
|
-
targetX,
|
|
676
|
-
targetY,
|
|
677
|
-
targetWidth,
|
|
678
|
-
targetHeight,
|
|
679
|
-
targetRadius,
|
|
680
|
-
opacity,
|
|
681
|
-
zoneBorderWidth,
|
|
682
|
-
steps,
|
|
683
|
-
config,
|
|
684
|
-
containerRef,
|
|
685
|
-
scrollViewRef,
|
|
686
|
-
setScrollViewRef,
|
|
687
|
-
currentZoneStyle,
|
|
688
|
-
clearProgress,
|
|
689
|
-
hasSavedProgress,
|
|
690
|
-
orderedStepKeys,
|
|
691
|
-
]
|
|
692
|
-
);
|
|
693
|
-
|
|
694
|
-
return (
|
|
695
|
-
<TourContext.Provider value={value}>
|
|
696
|
-
<AnimatedView
|
|
697
|
-
ref={containerRef}
|
|
698
|
-
style={styles.container}
|
|
699
|
-
collapsable={false}
|
|
700
|
-
>
|
|
701
|
-
{children}
|
|
702
|
-
<TourOverlay />
|
|
703
|
-
<TourTooltip />
|
|
704
|
-
</AnimatedView>
|
|
705
|
-
</TourContext.Provider>
|
|
706
|
-
);
|
|
707
|
-
};
|
|
708
|
-
|
|
709
|
-
const styles = StyleSheet.create({
|
|
710
|
-
container: {
|
|
711
|
-
flex: 1,
|
|
712
|
-
},
|
|
713
|
-
});
|