@wireai/activation 0.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.
- package/AGENTS.md +73 -0
- package/CHANGELOG.md +572 -0
- package/INTEGRATION_PROMPT.md +55 -0
- package/LICENSE +21 -0
- package/README.md +923 -0
- package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
- package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
- package/dist/coachmarks/index.d.mts +352 -0
- package/dist/coachmarks/index.d.ts +352 -0
- package/dist/coachmarks/index.js +944 -0
- package/dist/coachmarks/index.js.map +1 -0
- package/dist/coachmarks/index.mjs +918 -0
- package/dist/coachmarks/index.mjs.map +1 -0
- package/dist/decision-CFvGY6nP.d.ts +228 -0
- package/dist/decision-Cl8OFYzu.d.mts +228 -0
- package/dist/index.d.mts +1390 -0
- package/dist/index.d.ts +1390 -0
- package/dist/index.js +3260 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3186 -0
- package/dist/index.mjs.map +1 -0
- package/dist/questionnaire/index.d.mts +249 -0
- package/dist/questionnaire/index.d.ts +249 -0
- package/dist/questionnaire/index.js +922 -0
- package/dist/questionnaire/index.js.map +1 -0
- package/dist/questionnaire/index.mjs +906 -0
- package/dist/questionnaire/index.mjs.map +1 -0
- package/dist/reviews/index.d.mts +213 -0
- package/dist/reviews/index.d.ts +213 -0
- package/dist/reviews/index.js +1001 -0
- package/dist/reviews/index.js.map +1 -0
- package/dist/reviews/index.mjs +971 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/showcase/index.d.mts +119 -0
- package/dist/showcase/index.d.ts +119 -0
- package/dist/showcase/index.js +660 -0
- package/dist/showcase/index.js.map +1 -0
- package/dist/showcase/index.mjs +651 -0
- package/dist/showcase/index.mjs.map +1 -0
- package/dist/types-BKfpdZzX.d.mts +84 -0
- package/dist/types-BKfpdZzX.d.ts +84 -0
- package/dist/types-CMuOexw0.d.mts +116 -0
- package/dist/types-CMuOexw0.d.ts +116 -0
- package/dist/types-GL_hQ0TN.d.mts +46 -0
- package/dist/types-GL_hQ0TN.d.ts +46 -0
- package/llms.txt +36 -0
- package/metro/index.d.ts +32 -0
- package/metro/index.js +83 -0
- package/package.json +171 -0
- package/src/DemoOnboarding.tsx +133 -0
- package/src/OnboardingFlow.tsx +504 -0
- package/src/WireOnboarding.tsx +248 -0
- package/src/analytics/analyticsEvent.ts +69 -0
- package/src/analytics/reportClientEvent.ts +119 -0
- package/src/analytics/sendPreview.ts +73 -0
- package/src/attribution/attribution.ts +46 -0
- package/src/cards/ChipSelectCard.tsx +265 -0
- package/src/cards/InterstitialCard.tsx +299 -0
- package/src/cards/NumberStepperCard.tsx +121 -0
- package/src/cards/SelectionCard.tsx +270 -0
- package/src/cards/StatusCard.tsx +170 -0
- package/src/cards/TextInputCard.tsx +147 -0
- package/src/cards/index.ts +23 -0
- package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
- package/src/coachmarks/CoachmarkProvider.tsx +108 -0
- package/src/coachmarks/GestureHint.tsx +331 -0
- package/src/coachmarks/SpotlightOverlay.tsx +254 -0
- package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
- package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
- package/src/coachmarks/index.ts +62 -0
- package/src/coachmarks/runtime.ts +103 -0
- package/src/coachmarks/selectTourSteps.ts +45 -0
- package/src/coachmarks/types.ts +58 -0
- package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
- package/src/coachmarks/useCoachmarkTour.ts +200 -0
- package/src/components/AnimatedSparkle.tsx +101 -0
- package/src/components/Button.tsx +129 -0
- package/src/components/CardHandoff.tsx +212 -0
- package/src/components/CardLayout.tsx +103 -0
- package/src/components/CenteredModal.tsx +184 -0
- package/src/components/CompletionView.tsx +186 -0
- package/src/components/DoneBlock.tsx +37 -0
- package/src/components/ErrorBlock.tsx +42 -0
- package/src/components/Illustration.tsx +36 -0
- package/src/components/LoadingBlock.tsx +182 -0
- package/src/components/LoadingScreen.tsx +35 -0
- package/src/components/OnboardingScaffold.tsx +105 -0
- package/src/components/StepProgress.tsx +85 -0
- package/src/components/loaderChrome.ts +28 -0
- package/src/config/onboardingFlag.ts +39 -0
- package/src/config/wireConfigFromEnv.ts +52 -0
- package/src/device/deviceContext.ts +158 -0
- package/src/features/WireFeaturesProvider.tsx +81 -0
- package/src/features/cache.ts +92 -0
- package/src/features/defaults.ts +67 -0
- package/src/features/fetchWireFeatures.ts +86 -0
- package/src/features/index.ts +39 -0
- package/src/features/types.ts +61 -0
- package/src/features/useWireFeatures.ts +57 -0
- package/src/identity/userIdentity.ts +100 -0
- package/src/illustrations/defaultIllustrations.tsx +99 -0
- package/src/index.ts +159 -0
- package/src/motion/centeredModalMotion.ts +45 -0
- package/src/motion/motionSpec.ts +133 -0
- package/src/motion/useReducedMotion.ts +43 -0
- package/src/questionnaire/QuestionnaireGate.tsx +235 -0
- package/src/questionnaire/decision.ts +63 -0
- package/src/questionnaire/index.ts +54 -0
- package/src/questionnaire/runtime.ts +20 -0
- package/src/questionnaire/transport.ts +83 -0
- package/src/questionnaire/types.ts +182 -0
- package/src/questionnaire/useQuestionnaireGate.ts +159 -0
- package/src/reviews/ReviewGate.tsx +316 -0
- package/src/reviews/ReviewModal.tsx +13 -0
- package/src/reviews/decision.ts +120 -0
- package/src/reviews/equality.ts +54 -0
- package/src/reviews/expo-store-review.d.ts +16 -0
- package/src/reviews/feedbackForm.ts +41 -0
- package/src/reviews/index.ts +78 -0
- package/src/reviews/presentation.ts +29 -0
- package/src/reviews/runtime.ts +45 -0
- package/src/reviews/storeReview.ts +62 -0
- package/src/reviews/transport.ts +89 -0
- package/src/reviews/types.ts +172 -0
- package/src/reviews/useReviewGate.ts +137 -0
- package/src/session/persistedSession.ts +143 -0
- package/src/session-analytics/index.ts +18 -0
- package/src/session-analytics/reportSessionStart.ts +144 -0
- package/src/session-analytics/useSessionStart.ts +114 -0
- package/src/showcase/FeatureShowcase.tsx +233 -0
- package/src/showcase/index.ts +19 -0
- package/src/showcase/selectShowcaseSlides.ts +45 -0
- package/src/showcase/showcaseColors.ts +91 -0
- package/src/showcase/types.ts +74 -0
- package/src/theme/ThemeContext.tsx +38 -0
- package/src/theme/defaultTheme.ts +70 -0
- package/src/theme/themeFromBrand.ts +93 -0
- package/src/theme/types.ts +88 -0
- package/src/theme/typography.ts +46 -0
- package/src/types.ts +215 -0
- package/src/utils/deriveAnswers.ts +43 -0
- package/src/utils/readProgress.ts +33 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
4
|
+
import type { WireFeatures, WireFeaturesConfig } from "../features/types";
|
|
5
|
+
import { CoachmarkOverlayHost } from "./CoachmarkOverlayHost";
|
|
6
|
+
import {
|
|
7
|
+
getCoachmarkStorage,
|
|
8
|
+
setCoachmarkStorage,
|
|
9
|
+
setCoachmarksEnabled,
|
|
10
|
+
setCoachmarkTesting,
|
|
11
|
+
} from "./runtime";
|
|
12
|
+
import type { CoachmarkStorage } from "./types";
|
|
13
|
+
|
|
14
|
+
export interface CoachmarkProviderProps {
|
|
15
|
+
/**
|
|
16
|
+
* SYNCHRONOUS gate storage (MMKV-friendly). Reads must not be async or a ring
|
|
17
|
+
* would flash before the "already seen" gate resolves. See CoachmarkStorage.
|
|
18
|
+
*/
|
|
19
|
+
storage: CoachmarkStorage;
|
|
20
|
+
/**
|
|
21
|
+
* QA replay switch. When true, every seen-gate reads as unseen AND seen-writes
|
|
22
|
+
* are suppressed, so every tour + showcase replays on each mount. Flip ONE
|
|
23
|
+
* boolean to re-see the whole coachmark surface.
|
|
24
|
+
*/
|
|
25
|
+
isTestingCoachmark?: boolean;
|
|
26
|
+
/** Ring + tooltip color for every tour. Defaults to the onboarding theme's `primary`. */
|
|
27
|
+
accentColor?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Text/icon color on the accent surface (e.g. the tooltip's CTA label).
|
|
30
|
+
* Defaults to the onboarding theme's `onPrimary`.
|
|
31
|
+
*/
|
|
32
|
+
accentTextColor?: string;
|
|
33
|
+
/**
|
|
34
|
+
* OPTIONAL feature-flag wiring. When `flags.coachmarks.enabled` is false, every tour + overlay
|
|
35
|
+
* request becomes a silent no-op (once-gates are NOT consumed, so re-enabling replays). Provide
|
|
36
|
+
* pre-resolved `features`, OR a `featuresConfig` (serverUrl + apiKey) to lazily fetch once, OR
|
|
37
|
+
* mount a `WireFeaturesProvider` above (context is read automatically). Omit all three and
|
|
38
|
+
* coachmarks stay on (fail-open) — zero behavior change for hosts that never adopt flags.
|
|
39
|
+
*/
|
|
40
|
+
features?: WireFeatures;
|
|
41
|
+
/** Lazy-fetch config for the flags, used when `features` is absent and no provider is above. */
|
|
42
|
+
featuresConfig?: WireFeaturesConfig;
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mount ONCE at the app root — wrap it around (or just inside) your
|
|
48
|
+
* NavigationContainer so the overlay host is a root-level sibling of the whole
|
|
49
|
+
* app tree. That placement is what lets a ring + blur paint ABOVE the bottom tab
|
|
50
|
+
* bar, which react-navigation renders over screen components.
|
|
51
|
+
*
|
|
52
|
+
* It feeds the sync storage + testing flag into the engine's module-level
|
|
53
|
+
* singletons so `useCoachmarkTour` / `useCoachmarkAnchor` work from anywhere in
|
|
54
|
+
* the tree without prop-drilling, then renders the overlay host as a sibling
|
|
55
|
+
* after `children` (the SpotlightOverlay self-positions absolute-fill at
|
|
56
|
+
* zIndex/elevation 9999).
|
|
57
|
+
*
|
|
58
|
+
* The singletons are set during render (not in an effect) so a child's mount
|
|
59
|
+
* effect — which runs BEFORE this parent's effects — already sees the storage
|
|
60
|
+
* when it reads its gate.
|
|
61
|
+
*/
|
|
62
|
+
export const CoachmarkProvider: React.FC<CoachmarkProviderProps> = ({
|
|
63
|
+
storage,
|
|
64
|
+
isTestingCoachmark = false,
|
|
65
|
+
accentColor,
|
|
66
|
+
accentTextColor,
|
|
67
|
+
features,
|
|
68
|
+
featuresConfig,
|
|
69
|
+
children,
|
|
70
|
+
}) => {
|
|
71
|
+
// Resolve the coachmarks kill switch: explicit `features` → context → lazy fetch → all-on.
|
|
72
|
+
const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
|
|
73
|
+
const coachmarksOn = flags.coachmarks.enabled;
|
|
74
|
+
|
|
75
|
+
// Apply during render so gates + the kill switch are readable before any child effect fires.
|
|
76
|
+
setCoachmarkStorage(storage);
|
|
77
|
+
setCoachmarkTesting(isTestingCoachmark);
|
|
78
|
+
setCoachmarksEnabled(coachmarksOn);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
setCoachmarkStorage(storage);
|
|
82
|
+
setCoachmarkTesting(isTestingCoachmark);
|
|
83
|
+
setCoachmarksEnabled(coachmarksOn);
|
|
84
|
+
return () => {
|
|
85
|
+
// Only clear if THIS provider's storage is still the live singleton — a
|
|
86
|
+
// newer provider that mounted before this one unmounts must not be wiped.
|
|
87
|
+
if (getCoachmarkStorage() === storage) {
|
|
88
|
+
setCoachmarkStorage(null);
|
|
89
|
+
}
|
|
90
|
+
// Restore the fail-open default so a torn-down provider never leaves coachmarks dark.
|
|
91
|
+
setCoachmarksEnabled(true);
|
|
92
|
+
};
|
|
93
|
+
}, [storage, isTestingCoachmark, coachmarksOn]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
{children}
|
|
98
|
+
<CoachmarkOverlayHost
|
|
99
|
+
accentColor={accentColor}
|
|
100
|
+
accentTextColor={accentTextColor}
|
|
101
|
+
/>
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
CoachmarkProvider.displayName = "CoachmarkProvider";
|
|
107
|
+
|
|
108
|
+
export default CoachmarkProvider;
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useReducedMotion,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withDelay,
|
|
8
|
+
withRepeat,
|
|
9
|
+
withSequence,
|
|
10
|
+
withTiming,
|
|
11
|
+
} from "react-native-reanimated";
|
|
12
|
+
|
|
13
|
+
import type { GestureKind } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Abstract gesture pictogram shown beside a coachmark tooltip. Pure reanimated —
|
|
17
|
+
* every animation runs on the UI thread (shared values + worklets), never
|
|
18
|
+
* setState, so it costs nothing on the JS thread. Honors Reduce Motion by
|
|
19
|
+
* dropping the motion loop and rendering the glyph static.
|
|
20
|
+
*
|
|
21
|
+
* NO drawn-hand anatomy. Swipes render the standard two-finger swipe glyph: two
|
|
22
|
+
* rounded vertical capsules (an abstract index + middle finger, slightly
|
|
23
|
+
* different heights) with a short, fading motion track trailing behind them along
|
|
24
|
+
* the swipe axis. The whole glyph is rotated per direction (up/down/left/right)
|
|
25
|
+
* and the capsules slide along the axis, fading, in a UI-thread loop. Pinch/zoom
|
|
26
|
+
* reuse the two-capsule abstraction spreading apart. Tap / double-tap render a
|
|
27
|
+
* minimal pulsing tap-target (ring + dot).
|
|
28
|
+
*
|
|
29
|
+
* Every glyph is a self-drawn primitive built from Views/borders (no vector-icon
|
|
30
|
+
* dependency, like AnimatedSparkle). Pass `renderHand` — the glyph slot — to
|
|
31
|
+
* substitute your own node while keeping the tuned motion. (The name is kept for
|
|
32
|
+
* backward compatibility; despite it, the default is a glyph, not a drawn hand.)
|
|
33
|
+
*
|
|
34
|
+
* NOTE: the coachmark path (SpotlightOverlay) and the showcase path
|
|
35
|
+
* (FeatureShowcase) both SKIP this component for `tap` / `double_tap` — those
|
|
36
|
+
* steps show only the highlight ring + tooltip, no glyph. The tap visual here
|
|
37
|
+
* exists for direct / standalone use.
|
|
38
|
+
*/
|
|
39
|
+
export interface GestureHintProps {
|
|
40
|
+
type: GestureKind;
|
|
41
|
+
/** Overall glyph size in px. */
|
|
42
|
+
size?: number;
|
|
43
|
+
/** Glyph color. Defaults to white (reads on the dark blur backdrop). */
|
|
44
|
+
color?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Provide a custom glyph node in place of the default self-drawn primitive; it
|
|
47
|
+
* inherits the tuned motion (slide+fade for swipes, pulse for tap). Kept as
|
|
48
|
+
* `renderHand` for backward compatibility — despite the name it is just the
|
|
49
|
+
* glyph slot, no longer a drawn hand.
|
|
50
|
+
*/
|
|
51
|
+
renderHand?: (props: { size: number; color: string }) => ReactNode;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Travel distance for the directional swipe loop (local glyph axis). */
|
|
55
|
+
const TRAVEL = 26;
|
|
56
|
+
const SWIPE_MS = 700;
|
|
57
|
+
const TAP_MS = 320;
|
|
58
|
+
const SPREAD_MS = 620;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-direction rotation of the whole glyph. Canonical orientation is "up":
|
|
62
|
+
* capsules point up, travel up, motion track trailing below. Rotating the
|
|
63
|
+
* container reorients both the capsules and the track together, and rotates the
|
|
64
|
+
* local translate axis so the same upward loop reads correctly per direction.
|
|
65
|
+
*/
|
|
66
|
+
const GLYPH_ROTATION: Record<string, string> = {
|
|
67
|
+
swipe_up: "0deg",
|
|
68
|
+
swipe_down: "180deg",
|
|
69
|
+
swipe_left: "-90deg",
|
|
70
|
+
swipe_right: "90deg",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The two-finger abstraction: two rounded vertical capsules side by side with
|
|
75
|
+
* slightly different heights (index taller than middle), aligned at the base —
|
|
76
|
+
* the standard swipe pictogram look. Self-drawn from Views only.
|
|
77
|
+
*/
|
|
78
|
+
const FingerPair: React.FC<{ size: number; color: string }> = ({ size, color }) => {
|
|
79
|
+
const { row, index, middle } = useMemo(() => {
|
|
80
|
+
const w = size * 0.2;
|
|
81
|
+
const r = w / 2;
|
|
82
|
+
return {
|
|
83
|
+
row: { flexDirection: "row" as const, alignItems: "flex-end" as const, gap: size * 0.08 },
|
|
84
|
+
index: { width: w, height: size * 0.54, borderRadius: r, backgroundColor: color },
|
|
85
|
+
middle: { width: w, height: size * 0.44, borderRadius: r, backgroundColor: color },
|
|
86
|
+
};
|
|
87
|
+
}, [size, color]);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<View style={row} pointerEvents="none">
|
|
91
|
+
<View style={index} />
|
|
92
|
+
<View style={middle} />
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A short, tapering motion track that sits behind the capsules (below, in the
|
|
99
|
+
* canonical "up" orientation) — two slim, fading ticks that shrink and dim with
|
|
100
|
+
* distance. Subtle: it reads as the path the fingers just travelled, not a comet
|
|
101
|
+
* tail. Static (the capsules carry the motion via the slide loop).
|
|
102
|
+
*/
|
|
103
|
+
const MotionTrack: React.FC<{ size: number; color: string }> = ({ size, color }) => {
|
|
104
|
+
const { wrap, tick0, tick1 } = useMemo(() => {
|
|
105
|
+
const h = Math.max(2, size * 0.05);
|
|
106
|
+
return {
|
|
107
|
+
wrap: { alignItems: "center" as const, marginTop: size * 0.1, gap: size * 0.06 },
|
|
108
|
+
tick0: {
|
|
109
|
+
width: size * 0.3,
|
|
110
|
+
height: h,
|
|
111
|
+
borderRadius: h / 2,
|
|
112
|
+
backgroundColor: color,
|
|
113
|
+
opacity: 0.4,
|
|
114
|
+
},
|
|
115
|
+
tick1: {
|
|
116
|
+
width: size * 0.18,
|
|
117
|
+
height: h,
|
|
118
|
+
borderRadius: h / 2,
|
|
119
|
+
backgroundColor: color,
|
|
120
|
+
opacity: 0.16,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}, [size, color]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<View style={wrap} pointerEvents="none">
|
|
127
|
+
<View style={tick0} />
|
|
128
|
+
<View style={tick1} />
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const GestureHintComponent: React.FC<GestureHintProps> = ({
|
|
134
|
+
type,
|
|
135
|
+
size = 34,
|
|
136
|
+
color = "white",
|
|
137
|
+
renderHand,
|
|
138
|
+
}) => {
|
|
139
|
+
const translate = useSharedValue(0);
|
|
140
|
+
const opacity = useSharedValue(1);
|
|
141
|
+
const pulse = useSharedValue(1);
|
|
142
|
+
const spread = useSharedValue(0);
|
|
143
|
+
const reduced = useReducedMotion();
|
|
144
|
+
|
|
145
|
+
const isSwipe =
|
|
146
|
+
type === "swipe_up" ||
|
|
147
|
+
type === "swipe_down" ||
|
|
148
|
+
type === "swipe_left" ||
|
|
149
|
+
type === "swipe_right";
|
|
150
|
+
const isSpread = type === "pinch" || type === "zoom";
|
|
151
|
+
const isTap = type === "tap" || type === "double_tap";
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (type === "none" || reduced) return undefined;
|
|
155
|
+
|
|
156
|
+
if (isTap) {
|
|
157
|
+
pulse.value = withRepeat(
|
|
158
|
+
withSequence(
|
|
159
|
+
withTiming(0.82, { duration: TAP_MS }),
|
|
160
|
+
withTiming(1, { duration: TAP_MS }),
|
|
161
|
+
),
|
|
162
|
+
-1,
|
|
163
|
+
false,
|
|
164
|
+
);
|
|
165
|
+
opacity.value = withRepeat(
|
|
166
|
+
withSequence(
|
|
167
|
+
withTiming(0.55, { duration: TAP_MS }),
|
|
168
|
+
withTiming(1, { duration: TAP_MS }),
|
|
169
|
+
),
|
|
170
|
+
-1,
|
|
171
|
+
false,
|
|
172
|
+
);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (isSpread) {
|
|
177
|
+
// Two capsules easing apart and back — a minimal pinch/zoom abstraction.
|
|
178
|
+
spread.value = withRepeat(
|
|
179
|
+
withSequence(
|
|
180
|
+
withTiming(size * 0.26, { duration: SPREAD_MS }),
|
|
181
|
+
withTiming(0, { duration: SPREAD_MS }),
|
|
182
|
+
),
|
|
183
|
+
-1,
|
|
184
|
+
false,
|
|
185
|
+
);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Directional swipe: slide the capsules along the local "up" axis, fading
|
|
190
|
+
// out, then snap back and repeat. The container rotation (below) reorients
|
|
191
|
+
// this loop per direction, so the local motion is always the same.
|
|
192
|
+
translate.value = withRepeat(
|
|
193
|
+
withSequence(
|
|
194
|
+
withTiming(0, { duration: 0 }),
|
|
195
|
+
withTiming(-TRAVEL, { duration: SWIPE_MS }),
|
|
196
|
+
withDelay(140, withTiming(0, { duration: 0 })),
|
|
197
|
+
),
|
|
198
|
+
-1,
|
|
199
|
+
false,
|
|
200
|
+
);
|
|
201
|
+
opacity.value = withRepeat(
|
|
202
|
+
withSequence(
|
|
203
|
+
withTiming(1, { duration: SWIPE_MS / 2 }),
|
|
204
|
+
withTiming(0, { duration: SWIPE_MS / 2 }),
|
|
205
|
+
withDelay(140, withTiming(1, { duration: 0 })),
|
|
206
|
+
),
|
|
207
|
+
-1,
|
|
208
|
+
false,
|
|
209
|
+
);
|
|
210
|
+
return undefined;
|
|
211
|
+
}, [type, reduced, isTap, isSpread, translate, opacity, pulse, spread, size]);
|
|
212
|
+
|
|
213
|
+
const swipeAnimatedStyle = useAnimatedStyle(() => ({
|
|
214
|
+
opacity: opacity.value,
|
|
215
|
+
transform: [{ translateY: translate.value }],
|
|
216
|
+
}));
|
|
217
|
+
const tapAnimatedStyle = useAnimatedStyle(() => ({
|
|
218
|
+
opacity: opacity.value,
|
|
219
|
+
transform: [{ scale: pulse.value }],
|
|
220
|
+
}));
|
|
221
|
+
const spreadLeftStyle = useAnimatedStyle(() => ({
|
|
222
|
+
transform: [{ translateX: -spread.value }],
|
|
223
|
+
}));
|
|
224
|
+
const spreadRightStyle = useAnimatedStyle(() => ({
|
|
225
|
+
transform: [{ translateX: spread.value }],
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
if (type === "none") return null;
|
|
229
|
+
|
|
230
|
+
const customGlyph = renderHand ? renderHand({ size, color }) : null;
|
|
231
|
+
|
|
232
|
+
if (isSwipe) {
|
|
233
|
+
return (
|
|
234
|
+
<View
|
|
235
|
+
style={{ transform: [{ rotate: GLYPH_ROTATION[type] ?? "0deg" }] }}
|
|
236
|
+
pointerEvents="none"
|
|
237
|
+
>
|
|
238
|
+
<View style={styles.swipeStack}>
|
|
239
|
+
<Animated.View style={swipeAnimatedStyle} pointerEvents="none">
|
|
240
|
+
{customGlyph ?? <FingerPair size={size} color={color} />}
|
|
241
|
+
</Animated.View>
|
|
242
|
+
<MotionTrack size={size} color={color} />
|
|
243
|
+
</View>
|
|
244
|
+
</View>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isSpread) {
|
|
249
|
+
const w = size * 0.2;
|
|
250
|
+
const capsule = {
|
|
251
|
+
width: w,
|
|
252
|
+
height: size * 0.5,
|
|
253
|
+
borderRadius: w / 2,
|
|
254
|
+
backgroundColor: color,
|
|
255
|
+
};
|
|
256
|
+
return (
|
|
257
|
+
<View style={[styles.spreadRow, { gap: size * 0.12 }]} pointerEvents="none">
|
|
258
|
+
<Animated.View style={spreadLeftStyle}>
|
|
259
|
+
<View style={capsule} />
|
|
260
|
+
</Animated.View>
|
|
261
|
+
<Animated.View style={spreadRightStyle}>
|
|
262
|
+
<View style={capsule} />
|
|
263
|
+
</Animated.View>
|
|
264
|
+
</View>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// tap / double_tap — a minimal pulsing tap-target (ring + center dot).
|
|
269
|
+
const ringSize = size * 0.86;
|
|
270
|
+
const ringBorder = Math.max(2, size * 0.08);
|
|
271
|
+
const dotSize = size * 0.32;
|
|
272
|
+
return (
|
|
273
|
+
<View style={[styles.tapWrap, { width: size, height: size }]} pointerEvents="none">
|
|
274
|
+
{customGlyph ? (
|
|
275
|
+
<Animated.View style={tapAnimatedStyle} pointerEvents="none">
|
|
276
|
+
{customGlyph}
|
|
277
|
+
</Animated.View>
|
|
278
|
+
) : (
|
|
279
|
+
<>
|
|
280
|
+
<Animated.View
|
|
281
|
+
style={[
|
|
282
|
+
styles.tapRing,
|
|
283
|
+
{
|
|
284
|
+
width: ringSize,
|
|
285
|
+
height: ringSize,
|
|
286
|
+
borderRadius: ringSize / 2,
|
|
287
|
+
borderWidth: ringBorder,
|
|
288
|
+
borderColor: color,
|
|
289
|
+
},
|
|
290
|
+
tapAnimatedStyle,
|
|
291
|
+
]}
|
|
292
|
+
pointerEvents="none"
|
|
293
|
+
/>
|
|
294
|
+
<View
|
|
295
|
+
style={{
|
|
296
|
+
position: "absolute",
|
|
297
|
+
width: dotSize,
|
|
298
|
+
height: dotSize,
|
|
299
|
+
borderRadius: dotSize / 2,
|
|
300
|
+
backgroundColor: color,
|
|
301
|
+
}}
|
|
302
|
+
/>
|
|
303
|
+
</>
|
|
304
|
+
)}
|
|
305
|
+
</View>
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const styles = StyleSheet.create({
|
|
310
|
+
swipeStack: {
|
|
311
|
+
alignItems: "center",
|
|
312
|
+
justifyContent: "center",
|
|
313
|
+
},
|
|
314
|
+
spreadRow: {
|
|
315
|
+
flexDirection: "row",
|
|
316
|
+
alignItems: "center",
|
|
317
|
+
justifyContent: "center",
|
|
318
|
+
},
|
|
319
|
+
tapWrap: {
|
|
320
|
+
alignItems: "center",
|
|
321
|
+
justifyContent: "center",
|
|
322
|
+
},
|
|
323
|
+
tapRing: {
|
|
324
|
+
backgroundColor: "transparent",
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
export const GestureHint = React.memo(GestureHintComponent);
|
|
329
|
+
GestureHint.displayName = "GestureHint";
|
|
330
|
+
|
|
331
|
+
export default GestureHint;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { BlurView } from "expo-blur";
|
|
2
|
+
import React, { useEffect, useMemo } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
useWindowDimensions,
|
|
8
|
+
View,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import Animated, {
|
|
11
|
+
FadeIn,
|
|
12
|
+
FadeOut,
|
|
13
|
+
useAnimatedProps,
|
|
14
|
+
useAnimatedStyle,
|
|
15
|
+
useReducedMotion,
|
|
16
|
+
useSharedValue,
|
|
17
|
+
withRepeat,
|
|
18
|
+
withSequence,
|
|
19
|
+
withTiming,
|
|
20
|
+
} from "react-native-reanimated";
|
|
21
|
+
|
|
22
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
23
|
+
import { GestureHint } from "./GestureHint";
|
|
24
|
+
import type { GestureKind, Placement, TargetRect } from "./types";
|
|
25
|
+
|
|
26
|
+
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
|
|
27
|
+
|
|
28
|
+
const BLUR_INTENSITY = 26;
|
|
29
|
+
const FADE_MS = 280;
|
|
30
|
+
/** Breathing room between the highlighted element and the glowing ring. */
|
|
31
|
+
const RING_PADDING = 10;
|
|
32
|
+
/** Gap between the ring and the tooltip pill. */
|
|
33
|
+
const TOOLTIP_GAP = 14;
|
|
34
|
+
const SIDE_MARGIN = 20;
|
|
35
|
+
const PILL_MAX_WIDTH = 260;
|
|
36
|
+
|
|
37
|
+
export interface SpotlightOverlayProps {
|
|
38
|
+
message: string;
|
|
39
|
+
/** Window-space rect of the element to highlight. Null → centered hint (e.g. a scroll gesture). */
|
|
40
|
+
targetRect?: TargetRect | null;
|
|
41
|
+
gesture?: GestureKind;
|
|
42
|
+
placement?: Placement;
|
|
43
|
+
/** Tapping the highlighted target or the pill — advance / act. */
|
|
44
|
+
onEngage: () => void;
|
|
45
|
+
/** Tapping the dimmed backdrop — advance to the next step (ends the tour only on the last step). */
|
|
46
|
+
onDismiss: () => void;
|
|
47
|
+
/** Ring + tooltip color. Defaults to the onboarding theme's `primary`. */
|
|
48
|
+
accentColor?: string;
|
|
49
|
+
/** Tooltip label color. Defaults to the theme's `onPrimary`. */
|
|
50
|
+
accentTextColor?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A single coachmark step: the whole screen is frosted with an animated blur,
|
|
55
|
+
* and a bright pulsing ring + tooltip point at one measured target. Mounted at
|
|
56
|
+
* the app root (above the tab bar) via CoachmarkOverlayHost.
|
|
57
|
+
*
|
|
58
|
+
* Performance: one BlurView (mounted only while a step is visible), the ring
|
|
59
|
+
* glow is a single reanimated view, and all animation runs on the UI thread.
|
|
60
|
+
* Reduce Motion drops the pulse.
|
|
61
|
+
*/
|
|
62
|
+
const SpotlightOverlayComponent: React.FC<SpotlightOverlayProps> = ({
|
|
63
|
+
message,
|
|
64
|
+
targetRect,
|
|
65
|
+
gesture = "none",
|
|
66
|
+
placement = "bottom",
|
|
67
|
+
onEngage,
|
|
68
|
+
onDismiss,
|
|
69
|
+
accentColor,
|
|
70
|
+
accentTextColor,
|
|
71
|
+
}) => {
|
|
72
|
+
const { height: screenH } = useWindowDimensions();
|
|
73
|
+
const reduced = useReducedMotion();
|
|
74
|
+
const theme = useOnboardingTheme();
|
|
75
|
+
|
|
76
|
+
const accent = accentColor ?? theme.colors.primary;
|
|
77
|
+
const accentText = accentTextColor ?? theme.colors.onPrimary;
|
|
78
|
+
|
|
79
|
+
// Tap / double-tap steps show ONLY the ring + tooltip — no gesture glyph. The
|
|
80
|
+
// pulsing highlight ring already reads as "tap here", so an extra glyph is
|
|
81
|
+
// noise. Every app inherits this centrally, with no config change.
|
|
82
|
+
const showGesture =
|
|
83
|
+
gesture !== "none" && gesture !== "tap" && gesture !== "double_tap";
|
|
84
|
+
|
|
85
|
+
const intensity = useSharedValue(0);
|
|
86
|
+
const ringScale = useSharedValue(1);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
intensity.value = withTiming(BLUR_INTENSITY, { duration: FADE_MS });
|
|
90
|
+
}, [intensity]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (reduced) return;
|
|
94
|
+
ringScale.value = withRepeat(
|
|
95
|
+
withSequence(
|
|
96
|
+
withTiming(1.06, { duration: 700 }),
|
|
97
|
+
withTiming(1, { duration: 700 }),
|
|
98
|
+
),
|
|
99
|
+
-1,
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
102
|
+
}, [reduced, ringScale]);
|
|
103
|
+
|
|
104
|
+
const blurAnimatedProps = useAnimatedProps(() => ({
|
|
105
|
+
intensity: intensity.value,
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const ringAnimatedStyle = useAnimatedStyle(() => ({
|
|
109
|
+
transform: [{ scale: ringScale.value }],
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
// Geometry for the ring + tooltip, derived from the measured target.
|
|
113
|
+
const geometry = useMemo(() => {
|
|
114
|
+
if (!targetRect) return null;
|
|
115
|
+
const ringLeft = targetRect.x - RING_PADDING;
|
|
116
|
+
const ringTop = targetRect.y - RING_PADDING;
|
|
117
|
+
const ringW = targetRect.width + RING_PADDING * 2;
|
|
118
|
+
const ringH = targetRect.height + RING_PADDING * 2;
|
|
119
|
+
return {
|
|
120
|
+
ring: {
|
|
121
|
+
left: ringLeft,
|
|
122
|
+
top: ringTop,
|
|
123
|
+
width: ringW,
|
|
124
|
+
height: ringH,
|
|
125
|
+
borderRadius: Math.min(ringW, ringH) / 2,
|
|
126
|
+
},
|
|
127
|
+
// Tooltip above or below the ring depending on placement.
|
|
128
|
+
tooltip:
|
|
129
|
+
placement === "top"
|
|
130
|
+
? { bottom: screenH - ringTop + TOOLTIP_GAP }
|
|
131
|
+
: { top: ringTop + ringH + TOOLTIP_GAP },
|
|
132
|
+
};
|
|
133
|
+
}, [targetRect, placement, screenH]);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Animated.View
|
|
137
|
+
style={[StyleSheet.absoluteFill, styles.root]}
|
|
138
|
+
entering={FadeIn.duration(FADE_MS)}
|
|
139
|
+
exiting={FadeOut.duration(FADE_MS)}
|
|
140
|
+
>
|
|
141
|
+
<AnimatedBlurView
|
|
142
|
+
tint="dark"
|
|
143
|
+
animatedProps={blurAnimatedProps}
|
|
144
|
+
style={StyleSheet.absoluteFill}
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
{/* Tap-the-backdrop to move on (dismisses the tour on the last step). */}
|
|
148
|
+
<Pressable
|
|
149
|
+
style={StyleSheet.absoluteFill}
|
|
150
|
+
onPress={onDismiss}
|
|
151
|
+
accessibilityRole="button"
|
|
152
|
+
accessibilityLabel="Next hint"
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
{/* Glowing ring over the highlighted target (tap = engage). */}
|
|
156
|
+
{geometry && (
|
|
157
|
+
<Animated.View
|
|
158
|
+
style={[
|
|
159
|
+
styles.ring,
|
|
160
|
+
geometry.ring,
|
|
161
|
+
{ borderColor: accent, shadowColor: accent },
|
|
162
|
+
ringAnimatedStyle,
|
|
163
|
+
]}
|
|
164
|
+
pointerEvents="box-none"
|
|
165
|
+
>
|
|
166
|
+
<Pressable
|
|
167
|
+
style={StyleSheet.absoluteFill}
|
|
168
|
+
onPress={onEngage}
|
|
169
|
+
accessibilityRole="button"
|
|
170
|
+
accessibilityLabel={message}
|
|
171
|
+
/>
|
|
172
|
+
</Animated.View>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{/* Tooltip pill: anchored to the ring, or centered for a target-less hint. */}
|
|
176
|
+
<View
|
|
177
|
+
style={[
|
|
178
|
+
styles.tooltipWrapper,
|
|
179
|
+
geometry
|
|
180
|
+
? geometry.tooltip
|
|
181
|
+
: { top: screenH * 0.42, alignItems: "center" },
|
|
182
|
+
]}
|
|
183
|
+
pointerEvents="box-none"
|
|
184
|
+
>
|
|
185
|
+
{showGesture && (
|
|
186
|
+
<View style={styles.gestureSlot} pointerEvents="none">
|
|
187
|
+
<GestureHint type={gesture} color={accent} />
|
|
188
|
+
</View>
|
|
189
|
+
)}
|
|
190
|
+
<Pressable
|
|
191
|
+
onPress={onEngage}
|
|
192
|
+
style={[styles.pill, { backgroundColor: accent }]}
|
|
193
|
+
accessibilityRole="button"
|
|
194
|
+
accessibilityLabel={message}
|
|
195
|
+
>
|
|
196
|
+
<Text numberOfLines={3} style={[styles.pillText, { color: accentText }]}>
|
|
197
|
+
{message}
|
|
198
|
+
</Text>
|
|
199
|
+
</Pressable>
|
|
200
|
+
</View>
|
|
201
|
+
</Animated.View>
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const styles = StyleSheet.create({
|
|
206
|
+
// High elevation/zIndex so the whole overlay (incl. the frosted blur) sits
|
|
207
|
+
// above the bottom tab bar on Android, where sibling z-order follows
|
|
208
|
+
// elevation, not tree order.
|
|
209
|
+
root: {
|
|
210
|
+
zIndex: 9999,
|
|
211
|
+
elevation: 9999,
|
|
212
|
+
},
|
|
213
|
+
ring: {
|
|
214
|
+
position: "absolute",
|
|
215
|
+
borderWidth: 2.5,
|
|
216
|
+
backgroundColor: "transparent",
|
|
217
|
+
shadowOffset: { width: 0, height: 0 },
|
|
218
|
+
shadowOpacity: 0.9,
|
|
219
|
+
shadowRadius: 12,
|
|
220
|
+
elevation: 10,
|
|
221
|
+
},
|
|
222
|
+
tooltipWrapper: {
|
|
223
|
+
position: "absolute",
|
|
224
|
+
left: SIDE_MARGIN,
|
|
225
|
+
right: SIDE_MARGIN,
|
|
226
|
+
alignItems: "center",
|
|
227
|
+
gap: 10,
|
|
228
|
+
},
|
|
229
|
+
gestureSlot: {
|
|
230
|
+
alignItems: "center",
|
|
231
|
+
justifyContent: "center",
|
|
232
|
+
},
|
|
233
|
+
pill: {
|
|
234
|
+
maxWidth: PILL_MAX_WIDTH,
|
|
235
|
+
paddingVertical: 12,
|
|
236
|
+
paddingHorizontal: 18,
|
|
237
|
+
borderRadius: 18,
|
|
238
|
+
shadowColor: "#000",
|
|
239
|
+
shadowOffset: { width: 0, height: 2 },
|
|
240
|
+
shadowOpacity: 0.25,
|
|
241
|
+
shadowRadius: 6,
|
|
242
|
+
elevation: 6,
|
|
243
|
+
},
|
|
244
|
+
pillText: {
|
|
245
|
+
fontSize: 14,
|
|
246
|
+
fontWeight: "800",
|
|
247
|
+
textAlign: "center",
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const SpotlightOverlay = React.memo(SpotlightOverlayComponent);
|
|
252
|
+
SpotlightOverlay.displayName = "SpotlightOverlay";
|
|
253
|
+
|
|
254
|
+
export default SpotlightOverlay;
|