@akhilpulse/samadhaan-session-replay 0.2.7 → 0.3.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/README.md +2 -1
- package/android/build.gradle +42 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyModule.kt +97 -0
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyPackage.kt +14 -0
- package/package.json +3 -1
- package/react-native.config.js +12 -0
- package/src/SessionReplayProvider.tsx +105 -18
package/README.md
CHANGED
|
@@ -81,11 +81,12 @@ const { pause, resume, sessionId, status } = useSessionReplay();
|
|
|
81
81
|
|
|
82
82
|
## Critical constraints (don't skip)
|
|
83
83
|
|
|
84
|
-
1. Never use RN `<Modal>` for transient consent UI — use absolutely-positioned `<View>`
|
|
84
|
+
1. Never use RN `<Modal>` for transient consent UI — use absolutely-positioned `<View>` (no `zIndex` — see #6).
|
|
85
85
|
2. Never animate colors with `useNativeDriver: false` — causes 60fps JS commits that break touch handling on Fabric.
|
|
86
86
|
3. The guided-tour overlay must NOT cover the screen center. Use 4 thin edge strips with `pointerEvents: "none"`.
|
|
87
87
|
4. Pause screenshot capture while the iOS keyboard is visible.
|
|
88
88
|
5. Auto-mount the guided-tour overlay inside the provider, and singleton-guard it.
|
|
89
|
+
6. **Android elevated views & screenshots.** `react-native-view-shot` uses a software canvas draw on Android (`view.draw(canvas)`) which silently skips any view promoted to a hardware compositor layer (anything with `elevation > 0` or `zIndex > 0` — RN implements `zIndex` via `setElevation` on Android). To work around this, **starting in 0.3.0 the SDK ships its own native module that captures via Android's `PixelCopy` API** (real on-screen pixels, including hardware layers, modals, FABs, and native `SurfaceView`s like `react-native-maps`). This module is autolinked — **after upgrading the SDK on Android you must rebuild the app** (`cd android && ./gradlew clean && cd .. && npx react-native run-android`) for the new screenshot path to activate. If the native module isn't present at runtime (older build, Expo Go), the SDK transparently falls back to `captureScreen` and the elevation limitation re-applies. Requires Android 7.0+ (API 24).
|
|
89
90
|
|
|
90
91
|
## Publishing
|
|
91
92
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
|
|
4
|
+
def safeExtGet(prop, fallback) {
|
|
5
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
android {
|
|
9
|
+
namespace "com.akhilpulse.sessionreplay"
|
|
10
|
+
compileSdkVersion safeExtGet('compileSdkVersion', 34)
|
|
11
|
+
|
|
12
|
+
defaultConfig {
|
|
13
|
+
minSdkVersion safeExtGet('minSdkVersion', 24)
|
|
14
|
+
targetSdkVersion safeExtGet('targetSdkVersion', 34)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
compileOptions {
|
|
18
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
19
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
kotlinOptions {
|
|
23
|
+
jvmTarget = "17"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
sourceSets {
|
|
27
|
+
main {
|
|
28
|
+
java.srcDirs = ['src/main/java']
|
|
29
|
+
manifest.srcFile 'src/main/AndroidManifest.xml'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
repositories {
|
|
35
|
+
google()
|
|
36
|
+
mavenCentral()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
dependencies {
|
|
40
|
+
implementation "com.facebook.react:react-android"
|
|
41
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.24')}"
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package com.akhilpulse.sessionreplay
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.HandlerThread
|
|
8
|
+
import android.util.Base64
|
|
9
|
+
import android.view.PixelCopy
|
|
10
|
+
import com.facebook.react.bridge.Promise
|
|
11
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
12
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
13
|
+
import com.facebook.react.bridge.ReactMethod
|
|
14
|
+
import java.io.ByteArrayOutputStream
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Captures the Activity's window via Android PixelCopy, which reads the
|
|
18
|
+
* actual on-screen pixels (including hardware-accelerated layers like views
|
|
19
|
+
* with `elevation` / `zIndex`, modals, FABs, native SurfaceViews/MapViews).
|
|
20
|
+
*
|
|
21
|
+
* Returns a base64-encoded JPEG (no `data:` prefix) — same shape as
|
|
22
|
+
* react-native-view-shot's `result: "base64"` so the JS side can use it
|
|
23
|
+
* interchangeably.
|
|
24
|
+
*
|
|
25
|
+
* Requires API 24 (Android 7.0). Falls back to view-shot in JS otherwise.
|
|
26
|
+
*/
|
|
27
|
+
class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
|
|
28
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
29
|
+
|
|
30
|
+
override fun getName(): String = "SessionReplayPixelCopy"
|
|
31
|
+
|
|
32
|
+
@ReactMethod
|
|
33
|
+
fun capture(targetWidth: Double, quality: Double, promise: Promise) {
|
|
34
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
35
|
+
promise.reject("E_API", "PixelCopy requires Android 7.0+ (API 24)")
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
val activity: Activity? = currentActivity
|
|
39
|
+
if (activity == null) {
|
|
40
|
+
promise.reject("E_NO_ACTIVITY", "No current activity")
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
activity.runOnUiThread {
|
|
45
|
+
try {
|
|
46
|
+
val window = activity.window
|
|
47
|
+
val decor = window.decorView
|
|
48
|
+
val srcW = decor.width
|
|
49
|
+
val srcH = decor.height
|
|
50
|
+
if (srcW <= 0 || srcH <= 0) {
|
|
51
|
+
promise.reject("E_NO_SIZE", "Decor view has zero size")
|
|
52
|
+
return@runOnUiThread
|
|
53
|
+
}
|
|
54
|
+
val bitmap = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
|
|
55
|
+
|
|
56
|
+
val handlerThread = HandlerThread("SessionReplayPixelCopy")
|
|
57
|
+
handlerThread.start()
|
|
58
|
+
val handler = Handler(handlerThread.looper)
|
|
59
|
+
|
|
60
|
+
PixelCopy.request(
|
|
61
|
+
window,
|
|
62
|
+
bitmap,
|
|
63
|
+
{ result ->
|
|
64
|
+
try {
|
|
65
|
+
if (result == PixelCopy.SUCCESS) {
|
|
66
|
+
val tw = targetWidth.toInt().coerceAtLeast(1)
|
|
67
|
+
val scaled = if (tw < srcW) {
|
|
68
|
+
val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
|
|
69
|
+
Bitmap.createScaledBitmap(bitmap, tw, th, true)
|
|
70
|
+
} else bitmap
|
|
71
|
+
|
|
72
|
+
val q = (quality * 100).toInt().coerceIn(1, 100)
|
|
73
|
+
val baos = ByteArrayOutputStream()
|
|
74
|
+
scaled.compress(Bitmap.CompressFormat.JPEG, q, baos)
|
|
75
|
+
if (scaled !== bitmap) scaled.recycle()
|
|
76
|
+
bitmap.recycle()
|
|
77
|
+
|
|
78
|
+
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
|
79
|
+
promise.resolve(b64)
|
|
80
|
+
} else {
|
|
81
|
+
bitmap.recycle()
|
|
82
|
+
promise.reject("E_PIXELCOPY", "PixelCopy failed: $result")
|
|
83
|
+
}
|
|
84
|
+
} catch (e: Throwable) {
|
|
85
|
+
promise.reject("E_INNER", e)
|
|
86
|
+
} finally {
|
|
87
|
+
handlerThread.quitSafely()
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
handler
|
|
91
|
+
)
|
|
92
|
+
} catch (e: Throwable) {
|
|
93
|
+
promise.reject("E_OUTER", e)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package com.akhilpulse.sessionreplay
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
class SessionReplayPixelCopyPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
10
|
+
listOf(SessionReplayPixelCopyModule(reactContext))
|
|
11
|
+
|
|
12
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
13
|
+
emptyList()
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"source": "src/index.ts",
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"android",
|
|
13
|
+
"react-native.config.js",
|
|
12
14
|
"README.md"
|
|
13
15
|
],
|
|
14
16
|
"scripts": {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
dependency: {
|
|
3
|
+
platforms: {
|
|
4
|
+
android: {
|
|
5
|
+
sourceDir: './android',
|
|
6
|
+
packageImportPath: 'import com.akhilpulse.sessionreplay.SessionReplayPixelCopyPackage;',
|
|
7
|
+
packageInstance: 'new SessionReplayPixelCopyPackage()',
|
|
8
|
+
},
|
|
9
|
+
ios: null,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -31,12 +31,23 @@ import {
|
|
|
31
31
|
findNodeHandle,
|
|
32
32
|
StyleSheet,
|
|
33
33
|
Pressable,
|
|
34
|
+
NativeModules,
|
|
34
35
|
} from "react-native";
|
|
35
36
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
36
|
-
import { captureRef } from "react-native-view-shot";
|
|
37
|
+
import { captureRef, captureScreen } from "react-native-view-shot";
|
|
38
|
+
|
|
39
|
+
// Bundled native module (Android only) that uses PixelCopy to capture the
|
|
40
|
+
// real on-screen pixels — including hardware-accelerated layers (views with
|
|
41
|
+
// `elevation`/`zIndex`, modals, FABs, native MapViews). Autolinked via
|
|
42
|
+
// react-native.config.js. Will be `undefined` if the host app hasn't been
|
|
43
|
+
// rebuilt after upgrading the SDK, in which case we fall back to view-shot.
|
|
44
|
+
type PixelCopyNative = { capture: (targetWidth: number, quality: number) => Promise<string> };
|
|
45
|
+
const SessionReplayPixelCopy: PixelCopyNative | undefined =
|
|
46
|
+
Platform.OS === "android" ? (NativeModules as any).SessionReplayPixelCopy : undefined;
|
|
47
|
+
let pixelCopyDisabled = false; // turned true after a runtime failure to avoid retrying every frame
|
|
37
48
|
|
|
38
49
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
39
|
-
const SDK_VERSION = "0.
|
|
50
|
+
const SDK_VERSION = "0.3.0";
|
|
40
51
|
|
|
41
52
|
/** Default Samadhaan production endpoint. Override via `config.serverUrl` for staging. */
|
|
42
53
|
export const DEFAULT_SERVER_URL = "https://samadhaan.pulseenergy.io";
|
|
@@ -232,12 +243,33 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
232
243
|
const ref = rootRef.current;
|
|
233
244
|
if (ref) {
|
|
234
245
|
// Aggressively downscaled + low quality so capture+encode stays under ~50ms on mid devices.
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
// Android: native PixelCopy reads the actual on-screen pixels including
|
|
247
|
+
// hardware-accelerated layers (elevation/zIndex views, modals, FABs,
|
|
248
|
+
// native MapViews). Falls back to captureScreen if the native module
|
|
249
|
+
// isn't installed (host app hasn't rebuilt) or if PixelCopy fails.
|
|
250
|
+
// iOS: captureRef on the app root works correctly and stays scoped to the app.
|
|
251
|
+
const captureOpts = {
|
|
252
|
+
format: "jpg" as const,
|
|
237
253
|
quality: 0.25,
|
|
238
|
-
result: "base64",
|
|
254
|
+
result: "base64" as const,
|
|
239
255
|
width: 360,
|
|
240
|
-
}
|
|
256
|
+
};
|
|
257
|
+
let uri: string;
|
|
258
|
+
if (Platform.OS === "android") {
|
|
259
|
+
if (SessionReplayPixelCopy && !pixelCopyDisabled) {
|
|
260
|
+
try {
|
|
261
|
+
uri = await SessionReplayPixelCopy.capture(360, 0.25);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (__DEV__) console.warn("[session-replay] PixelCopy failed, falling back to view-shot:", err);
|
|
264
|
+
pixelCopyDisabled = true;
|
|
265
|
+
uri = await captureScreen(captureOpts);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
uri = await captureScreen(captureOpts);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
uri = await captureRef(ref as any, captureOpts);
|
|
272
|
+
}
|
|
241
273
|
const blob = base64ToUint8Array(uri);
|
|
242
274
|
// Stream binary over WS — no REST upload in the hot path.
|
|
243
275
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
@@ -356,6 +388,47 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
356
388
|
// Captured root-View size (in points) so we can place the agent pointer correctly.
|
|
357
389
|
const viewSizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
|
|
358
390
|
|
|
391
|
+
// ---- Draggable End-tour pill ----
|
|
392
|
+
// Animated translate offset relative to the pill's anchored position (top-right).
|
|
393
|
+
const endTourPan = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
|
|
394
|
+
const endTourPanResponder = useMemo(
|
|
395
|
+
() =>
|
|
396
|
+
PanResponder.create({
|
|
397
|
+
// Don't claim taps — let the inner Pressable receive onPress.
|
|
398
|
+
onStartShouldSetPanResponder: () => false,
|
|
399
|
+
// Only claim once the user clearly starts dragging (>4px).
|
|
400
|
+
onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 4 || Math.abs(g.dy) > 4,
|
|
401
|
+
onPanResponderGrant: () => {
|
|
402
|
+
endTourPan.extractOffset();
|
|
403
|
+
},
|
|
404
|
+
onPanResponderMove: Animated.event(
|
|
405
|
+
[null, { dx: endTourPan.x, dy: endTourPan.y }],
|
|
406
|
+
{ useNativeDriver: false }
|
|
407
|
+
),
|
|
408
|
+
onPanResponderRelease: () => {
|
|
409
|
+
endTourPan.flattenOffset();
|
|
410
|
+
// Clamp inside the visible root-view bounds so the pill never escapes off-screen.
|
|
411
|
+
const { w, h } = viewSizeRef.current;
|
|
412
|
+
const PILL_W = 110, PILL_H = 36, ANCHOR_TOP = 64, ANCHOR_RIGHT = 12, MARGIN = 8;
|
|
413
|
+
// Pill sits at top:64, right:12 by default. Translate ranges:
|
|
414
|
+
// x: from -(w - ANCHOR_RIGHT - PILL_W - MARGIN) to 0 (can move left from right edge)
|
|
415
|
+
// y: from -(ANCHOR_TOP - MARGIN) to (h - ANCHOR_TOP - PILL_H - MARGIN)
|
|
416
|
+
const minX = -(Math.max(0, w - ANCHOR_RIGHT - PILL_W - MARGIN));
|
|
417
|
+
const maxX = 0;
|
|
418
|
+
const minY = -(ANCHOR_TOP - MARGIN);
|
|
419
|
+
const maxY = Math.max(0, h - ANCHOR_TOP - PILL_H - MARGIN);
|
|
420
|
+
const cx = (endTourPan.x as any)._value;
|
|
421
|
+
const cy = (endTourPan.y as any)._value;
|
|
422
|
+
const nx = Math.min(maxX, Math.max(minX, cx));
|
|
423
|
+
const ny = Math.min(maxY, Math.max(minY, cy));
|
|
424
|
+
if (nx !== cx || ny !== cy) {
|
|
425
|
+
Animated.spring(endTourPan, { toValue: { x: nx, y: ny }, useNativeDriver: false, friction: 7 }).start();
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
429
|
+
[endTourPan]
|
|
430
|
+
);
|
|
431
|
+
|
|
359
432
|
function consentTour(accept: boolean) {
|
|
360
433
|
if (accept) {
|
|
361
434
|
setTourState((s) => ({ phase: "active", agentName: s.agentName }));
|
|
@@ -447,9 +520,14 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
447
520
|
<View pointerEvents="none" style={[styles.edge, { bottom: 0, left: 0, right: 0, height: 4 }]} />
|
|
448
521
|
<View pointerEvents="none" style={[styles.edge, { top: 0, bottom: 0, left: 0, width: 4 }]} />
|
|
449
522
|
<View pointerEvents="none" style={[styles.edge, { top: 0, bottom: 0, right: 0, width: 4 }]} />
|
|
450
|
-
<
|
|
451
|
-
|
|
452
|
-
|
|
523
|
+
<Animated.View
|
|
524
|
+
style={[styles.endTourPill, { transform: endTourPan.getTranslateTransform() }]}
|
|
525
|
+
{...endTourPanResponder.panHandlers}
|
|
526
|
+
>
|
|
527
|
+
<Pressable onPress={endTourLocal} style={({ pressed }) => [styles.endTourPressable, pressed && styles.btnPressed]} hitSlop={8}>
|
|
528
|
+
<Text style={styles.endTourText}>End tour</Text>
|
|
529
|
+
</Pressable>
|
|
530
|
+
</Animated.View>
|
|
453
531
|
{pointer && (
|
|
454
532
|
<>
|
|
455
533
|
<View pointerEvents="none" style={[styles.pointerHalo, { left: pointer.x - 24, top: pointer.y - 24 }]} />
|
|
@@ -511,15 +589,20 @@ const styles = StyleSheet.create({
|
|
|
511
589
|
position: "absolute", width: 40, height: 40, borderRadius: 20,
|
|
512
590
|
backgroundColor: "rgba(34,197,94,0.45)", borderWidth: 2, borderColor: "#22c55e",
|
|
513
591
|
},
|
|
592
|
+
// NOTE: We intentionally avoid `elevation` and `zIndex` on SDK overlays.
|
|
593
|
+
// On Android, both push the view into a hardware compositor layer that
|
|
594
|
+
// `react-native-view-shot`'s software canvas draw cannot capture, so the
|
|
595
|
+
// overlay would be invisible in screenshots/streamed frames. Tree order
|
|
596
|
+
// (overlays rendered AFTER children) already keeps them visually on top.
|
|
514
597
|
consentBackdrop: {
|
|
515
598
|
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
|
|
516
599
|
backgroundColor: "rgba(0,0,0,0.55)", justifyContent: "center", alignItems: "center",
|
|
517
|
-
paddingHorizontal: 24,
|
|
600
|
+
paddingHorizontal: 24,
|
|
518
601
|
},
|
|
519
602
|
consentCard: {
|
|
520
603
|
width: "100%", maxWidth: 360, backgroundColor: "#ffffff", borderRadius: 20,
|
|
521
604
|
paddingVertical: 24, paddingHorizontal: 22, alignItems: "center",
|
|
522
|
-
shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 24, shadowOffset: { width: 0, height: 12 },
|
|
605
|
+
shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 24, shadowOffset: { width: 0, height: 12 },
|
|
523
606
|
},
|
|
524
607
|
consentIconWrap: {
|
|
525
608
|
width: 56, height: 56, borderRadius: 28, alignItems: "center", justifyContent: "center",
|
|
@@ -545,23 +628,27 @@ const styles = StyleSheet.create({
|
|
|
545
628
|
btnGhostText: { color: "#475569", fontSize: 15, fontWeight: "600" },
|
|
546
629
|
btnPrimary: { backgroundColor: "#22c55e" },
|
|
547
630
|
btnPrimaryText: { color: "#fff", fontSize: 15, fontWeight: "600" },
|
|
548
|
-
edge: { position: "absolute", backgroundColor: "rgba(34,197,94,0.7)"
|
|
631
|
+
edge: { position: "absolute", backgroundColor: "rgba(34,197,94,0.7)" },
|
|
549
632
|
// End-tour pill: anchored to TOP-RIGHT with safe top inset (~64px) to clear iOS Dynamic Island & status bar.
|
|
633
|
+
// No `elevation`/`zIndex` — see consentBackdrop note above. Shadow is iOS-only and harmless on Android.
|
|
550
634
|
endTourPill: {
|
|
551
|
-
position: "absolute", top: 64, right: 12,
|
|
552
|
-
backgroundColor: "rgba(17,24,39,0.92)",
|
|
553
|
-
borderRadius: 999,
|
|
554
|
-
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 6, shadowOffset: { width: 0, height: 2 },
|
|
635
|
+
position: "absolute", top: 64, right: 12,
|
|
636
|
+
backgroundColor: "rgba(17,24,39,0.92)",
|
|
637
|
+
borderRadius: 999,
|
|
638
|
+
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 6, shadowOffset: { width: 0, height: 2 },
|
|
639
|
+
},
|
|
640
|
+
endTourPressable: {
|
|
641
|
+
paddingVertical: 8, paddingHorizontal: 14,
|
|
642
|
+
flexDirection: "row", alignItems: "center", borderRadius: 999,
|
|
555
643
|
},
|
|
556
644
|
endTourText: { color: "#fff", fontSize: 13, fontWeight: "600" },
|
|
557
645
|
pointer: {
|
|
558
646
|
position: "absolute", width: 24, height: 24, borderRadius: 12,
|
|
559
647
|
backgroundColor: "rgba(239,68,68,1)", borderWidth: 3, borderColor: "#fff",
|
|
560
648
|
shadowColor: "#ef4444", shadowOpacity: 0.9, shadowRadius: 8,
|
|
561
|
-
elevation: 12, zIndex: 10000,
|
|
562
649
|
},
|
|
563
650
|
pointerHalo: {
|
|
564
651
|
position: "absolute", width: 48, height: 48, borderRadius: 24,
|
|
565
|
-
backgroundColor: "rgba(239,68,68,0.25)",
|
|
652
|
+
backgroundColor: "rgba(239,68,68,0.25)",
|
|
566
653
|
},
|
|
567
654
|
});
|