@vanira/sdk-react-native 0.0.2

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 (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Simplified draw-shape parser for RN chalkboard (web MathBoard parity subset).
3
+ */
4
+
5
+ export type DrawShapeType =
6
+ | 'circle'
7
+ | 'rectangle'
8
+ | 'triangle'
9
+ | 'line'
10
+ | 'label'
11
+ | 'polygon'
12
+ | 'polyline'
13
+ | 'path';
14
+
15
+ export type BoardDrawShape = {
16
+ type: DrawShapeType;
17
+ summary: string;
18
+ };
19
+
20
+ function num(value: unknown): number | undefined {
21
+ if (typeof value === 'number' && Number.isFinite(value)) {
22
+ return value;
23
+ }
24
+ if (typeof value === 'string' && value.trim() !== '') {
25
+ const parsed = Number(value);
26
+ return Number.isFinite(parsed) ? parsed : undefined;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ function inferShapeType(args: Record<string, unknown>): DrawShapeType | null {
32
+ const figure = String(args.figure ?? '').toLowerCase();
33
+ if (figure.includes('circle')) {
34
+ return 'circle';
35
+ }
36
+ if (figure.includes('rect') || figure.includes('square')) {
37
+ return 'rectangle';
38
+ }
39
+ if (figure.includes('triangle')) {
40
+ return 'triangle';
41
+ }
42
+ if (figure.includes('line')) {
43
+ return 'line';
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export function buildShapeFromFlatArgs(
49
+ args: Record<string, unknown>,
50
+ ): BoardDrawShape | null {
51
+ const type = (
52
+ String(args.shape_type ?? args.type ?? '').toLowerCase() ||
53
+ inferShapeType(args) ||
54
+ ''
55
+ ) as DrawShapeType;
56
+
57
+ if (!type) {
58
+ const label = String(args.label_text ?? args.text ?? '').trim();
59
+ if (label) {
60
+ return {type: 'label', summary: `Label: ${label}`};
61
+ }
62
+ return null;
63
+ }
64
+
65
+ if (type === 'circle') {
66
+ const r = num(args.radius) ?? num(args.r) ?? 40;
67
+ const cx = num(args.center_x) ?? num(args.cx);
68
+ const cy = num(args.center_y) ?? num(args.cy);
69
+ return {
70
+ type: 'circle',
71
+ summary: `Circle r=${r}${cx !== undefined ? ` @(${cx},${cy})` : ''}`,
72
+ };
73
+ }
74
+
75
+ if (type === 'rectangle') {
76
+ const w = num(args.rect_width) ?? num(args.width) ?? 80;
77
+ const h = num(args.rect_height) ?? num(args.height) ?? 60;
78
+ return {type: 'rectangle', summary: `Rectangle ${w}×${h}`};
79
+ }
80
+
81
+ if (type === 'triangle') {
82
+ const kind = String(args.triangle_kind ?? args.figure ?? 'triangle');
83
+ return {type: 'triangle', summary: `Triangle (${kind})`};
84
+ }
85
+
86
+ if (type === 'line') {
87
+ return {type: 'line', summary: 'Line segment'};
88
+ }
89
+
90
+ if (type === 'label') {
91
+ const text = String(args.label_text ?? args.text ?? '').trim();
92
+ if (!text) {
93
+ return null;
94
+ }
95
+ return {type: 'label', summary: `Label: ${text}`};
96
+ }
97
+
98
+ return {type, summary: `Shape: ${type}`};
99
+ }
100
+
101
+ export function buildShapesFromArgs(
102
+ args: Record<string, unknown>,
103
+ ): BoardDrawShape[] {
104
+ const shapes: BoardDrawShape[] = [];
105
+ const single = buildShapeFromFlatArgs(args);
106
+ if (single) {
107
+ shapes.push(single);
108
+ }
109
+
110
+ const figure = String(args.figure ?? '').trim();
111
+ if (figure && !single) {
112
+ shapes.push({type: 'path', summary: `Figure: ${figure}`});
113
+ }
114
+
115
+ return shapes;
116
+ }
117
+
118
+ export function shapesToBoardAnnotation(shapes: BoardDrawShape[]): string {
119
+ if (shapes.length === 0) {
120
+ return '';
121
+ }
122
+ return `\n[Diagram: ${shapes.map(s => s.summary).join('; ')}]`;
123
+ }
@@ -0,0 +1,109 @@
1
+ import type {PresetContext} from '../types';
2
+
3
+ /** Port of vanira-sdk/src/ui/utils.ts cleanLatexText. */
4
+ export function cleanLatexText(text: string): string {
5
+ if (!text) {
6
+ return '';
7
+ }
8
+ return text
9
+ .replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '$1/$2')
10
+ .replace(/\\sqrt\{([^}]+)\}/g, '√$1')
11
+ .replace(/\\sqrt(?![a-zA-Z])/g, '√')
12
+ .replace(/\bsqrt\b/gi, '√')
13
+ .replace(/\\left(?![a-zA-Z])/g, '')
14
+ .replace(/\\right(?![a-zA-Z])/g, '')
15
+ .replace(/\\text\{([^}]+)\}/g, '$1')
16
+ .replace(/\\text\{([^}]+)$/g, '$1')
17
+ .replace(/\\text(?![a-zA-Z])/g, '')
18
+ .replace(/\\times(?![a-zA-Z])/g, '×')
19
+ .replace(/\\div(?![a-zA-Z])/g, '÷')
20
+ .replace(/\\pm(?![a-zA-Z])/g, '±')
21
+ .replace(/\\ge(q)?(?![a-zA-Z])/g, '≥')
22
+ .replace(/\\le(q)?(?![a-zA-Z])/g, '≤')
23
+ .replace(/\\ne(q)?(?![a-zA-Z])/g, '≠')
24
+ .replace(/\\approx(?![a-zA-Z])/g, '≈')
25
+ .replace(/\\cdot(?![a-zA-Z])/g, '·')
26
+ .replace(/\\degree(?![a-zA-Z])/g, '°')
27
+ .replace(/\\pi(?![a-zA-Z])/g, 'π')
28
+ .replace(/\\theta(?![a-zA-Z])/g, 'θ')
29
+ .replace(/\\infty(?![a-zA-Z])/g, '∞')
30
+ .replace(/[{}]/g, '')
31
+ .replace(/ +/g, ' ');
32
+ }
33
+
34
+ export function eraseLastWords(text: string, count: number): string {
35
+ if (!text) {
36
+ return '';
37
+ }
38
+ const trimmed = text.trimEnd();
39
+ if (!trimmed) {
40
+ return '';
41
+ }
42
+
43
+ let current = trimmed;
44
+ for (let i = 0; i < count; i++) {
45
+ const lastSpace = current.lastIndexOf(' ');
46
+ const lastNewline = current.lastIndexOf('\n');
47
+ const cutIndex = Math.max(lastSpace, lastNewline);
48
+ if (cutIndex === -1) {
49
+ current = '';
50
+ break;
51
+ }
52
+ current = current.slice(0, cutIndex).trimEnd();
53
+ }
54
+ return current;
55
+ }
56
+
57
+ function merged(ctx: PresetContext): Record<string, unknown> {
58
+ return {...ctx.args, ...ctx.clientFields};
59
+ }
60
+
61
+ export function parseTypeTextArgs(ctx: PresetContext): {
62
+ text: string;
63
+ delayMs: number;
64
+ } {
65
+ const fields = merged(ctx);
66
+ const rawText = String(
67
+ fields.text_to_type ??
68
+ fields.text ??
69
+ fields.value ??
70
+ fields.search_query ??
71
+ fields.query ??
72
+ '',
73
+ );
74
+ const delayMs = Number(
75
+ fields.delay_ms !== undefined ? fields.delay_ms : 50,
76
+ );
77
+ return {
78
+ text: cleanLatexText(rawText),
79
+ delayMs: Number.isFinite(delayMs) ? delayMs : 50,
80
+ };
81
+ }
82
+
83
+ export function parseEraseTextArgs(ctx: PresetContext): {
84
+ mode: 'all' | 'words';
85
+ numWords: number;
86
+ delayMs: number;
87
+ } {
88
+ const fields = merged(ctx);
89
+ const modeRaw = String(fields.mode ?? 'all')
90
+ .trim()
91
+ .toLowerCase();
92
+ const numWords = Number(
93
+ fields.num_words !== undefined
94
+ ? fields.num_words
95
+ : fields.words_count !== undefined
96
+ ? fields.words_count
97
+ : fields.count !== undefined
98
+ ? fields.count
99
+ : 1,
100
+ );
101
+ const delayMs = Number(
102
+ fields.delay_ms !== undefined ? fields.delay_ms : 50,
103
+ );
104
+ return {
105
+ mode: modeRaw === 'words' ? 'words' : 'all',
106
+ numWords: Number.isFinite(numWords) ? numWords : 1,
107
+ delayMs: Number.isFinite(delayMs) ? delayMs : 50,
108
+ };
109
+ }
@@ -0,0 +1,261 @@
1
+ import React, {useCallback, useRef, useState} from 'react';
2
+ import {
3
+ Modal,
4
+ PanResponder,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ useWindowDimensions,
10
+ } from 'react-native';
11
+ import {captureRef} from 'react-native-view-shot';
12
+ import type {PresetClient, PresetContext} from '../types';
13
+ import {getClipRegionCaptureTarget} from './clipRegionBridge';
14
+ import {resolveUploadBase} from '../streaming/mediaFrameUpload';
15
+
16
+ type Props = {
17
+ visible: boolean;
18
+ ctx: PresetContext;
19
+ client: PresetClient | null;
20
+ onClose: () => void;
21
+ onCancel: (reason?: string) => void;
22
+ onComplete: (payload: Record<string, unknown>) => void;
23
+ };
24
+
25
+ type Rect = {left: number; top: number; width: number; height: number};
26
+
27
+ export function ClipRegionModal({
28
+ visible,
29
+ ctx,
30
+ client,
31
+ onClose,
32
+ onCancel,
33
+ onComplete,
34
+ }: Props) {
35
+ const {width: screenW, height: screenH} = useWindowDimensions();
36
+ const [rect, setRect] = useState<Rect | null>(null);
37
+ const rectRef = useRef<Rect | null>(null);
38
+ const [capturing, setCapturing] = useState(false);
39
+ const startRef = useRef({x: 0, y: 0});
40
+ const merged = {...ctx.args, ...ctx.clientFields};
41
+ const hint = String(
42
+ merged.hint ?? 'Drag to select an area — release to clip.',
43
+ );
44
+ const minSize = Number(merged.min_region_px ?? 24);
45
+
46
+ const finalizeClip = useCallback(async () => {
47
+ const activeRect = rectRef.current;
48
+ if (!activeRect || activeRect.width < minSize || activeRect.height < minSize) {
49
+ setRect(null);
50
+ rectRef.current = null;
51
+ return;
52
+ }
53
+ if (!client) {
54
+ onCancel('No client available');
55
+ onClose();
56
+ return;
57
+ }
58
+
59
+ const target = getClipRegionCaptureTarget();
60
+ if (!target) {
61
+ onCancel(
62
+ 'Clip region capture target not registered. Ensure PresetHostProvider wraps your app.',
63
+ );
64
+ onClose();
65
+ return;
66
+ }
67
+
68
+ setCapturing(true);
69
+ try {
70
+ const uri = await captureRef(target, {
71
+ format: 'jpg',
72
+ quality: 0.85,
73
+ result: 'tmpfile',
74
+ });
75
+
76
+ const uploadBase = resolveUploadBase(client.serverUrl ?? '');
77
+ const uploadUrl = `${uploadBase}/media/upload`;
78
+ const form = new FormData();
79
+ form.append('file', {
80
+ uri,
81
+ type: 'image/jpeg',
82
+ name: `clip_${Date.now()}.jpg`,
83
+ } as unknown as Blob);
84
+ form.append('call_id', client.callId ?? '');
85
+ form.append('reason', String(merged.reason ?? 'region_clip'));
86
+
87
+ const res = await fetch(uploadUrl, {method: 'POST', body: form});
88
+ if (!res.ok) {
89
+ throw new Error(`Upload HTTP ${res.status}`);
90
+ }
91
+ const data = (await res.json()) as {media_id?: string; url?: string};
92
+ if (!data.media_id || !data.url) {
93
+ throw new Error('Upload response missing media_id/url');
94
+ }
95
+
96
+ const region = {
97
+ x: activeRect.left / screenW,
98
+ y: activeRect.top / screenH,
99
+ w: activeRect.width / screenW,
100
+ h: activeRect.height / screenH,
101
+ };
102
+
103
+ onComplete({
104
+ status: 'success',
105
+ preset_id: ctx.presetId,
106
+ media_id: data.media_id,
107
+ media_url: data.url,
108
+ region,
109
+ viewport: {width: screenW, height: screenH},
110
+ pixel_size: {width: activeRect.width, height: activeRect.height},
111
+ });
112
+ onClose();
113
+ } catch (err: unknown) {
114
+ const message = err instanceof Error ? err.message : 'Clip failed';
115
+ onCancel(message);
116
+ onClose();
117
+ } finally {
118
+ setCapturing(false);
119
+ setRect(null);
120
+ rectRef.current = null;
121
+ }
122
+ }, [
123
+ minSize,
124
+ client,
125
+ merged.reason,
126
+ screenW,
127
+ screenH,
128
+ onComplete,
129
+ onCancel,
130
+ onClose,
131
+ ctx.presetId,
132
+ ]);
133
+
134
+ const finalizeClipRef = useRef(finalizeClip);
135
+ finalizeClipRef.current = finalizeClip;
136
+
137
+ const panResponder = useRef(
138
+ PanResponder.create({
139
+ onStartShouldSetPanResponder: () => !capturing,
140
+ onMoveShouldSetPanResponder: () => !capturing,
141
+ onPanResponderGrant: evt => {
142
+ const {locationX, locationY} = evt.nativeEvent;
143
+ startRef.current = {x: locationX, y: locationY};
144
+ const next = {left: locationX, top: locationY, width: 0, height: 0};
145
+ rectRef.current = next;
146
+ setRect(next);
147
+ },
148
+ onPanResponderMove: evt => {
149
+ const {locationX, locationY} = evt.nativeEvent;
150
+ const start = startRef.current;
151
+ const next = {
152
+ left: Math.min(start.x, locationX),
153
+ top: Math.min(start.y, locationY),
154
+ width: Math.abs(locationX - start.x),
155
+ height: Math.abs(locationY - start.y),
156
+ };
157
+ rectRef.current = next;
158
+ setRect(next);
159
+ },
160
+ onPanResponderRelease: () => {
161
+ void finalizeClipRef.current();
162
+ },
163
+ }),
164
+ ).current;
165
+
166
+ if (!visible) {
167
+ return null;
168
+ }
169
+
170
+ return (
171
+ <Modal
172
+ visible
173
+ transparent
174
+ animationType="fade"
175
+ statusBarTranslucent
176
+ onRequestClose={() => {
177
+ onCancel('User cancelled');
178
+ onClose();
179
+ }}>
180
+ <View style={styles.root} {...panResponder.panHandlers}>
181
+ <View style={styles.toolbar}>
182
+ <Text style={styles.hint}>{hint}</Text>
183
+ <Pressable
184
+ style={styles.cancelBtn}
185
+ onPress={() => {
186
+ onCancel('User cancelled');
187
+ onClose();
188
+ }}>
189
+ <Text style={styles.cancelText}>Cancel</Text>
190
+ </Pressable>
191
+ </View>
192
+
193
+ {rect && rect.width > 0 && rect.height > 0 ? (
194
+ <View
195
+ style={[
196
+ styles.selection,
197
+ {
198
+ left: rect.left,
199
+ top: rect.top,
200
+ width: rect.width,
201
+ height: rect.height,
202
+ },
203
+ ]}
204
+ pointerEvents="none"
205
+ />
206
+ ) : null}
207
+
208
+ {capturing ? (
209
+ <View style={styles.capturingOverlay}>
210
+ <Text style={styles.capturingText}>Capturing…</Text>
211
+ </View>
212
+ ) : null}
213
+ </View>
214
+ </Modal>
215
+ );
216
+ }
217
+
218
+ const styles = StyleSheet.create({
219
+ root: {
220
+ flex: 1,
221
+ backgroundColor: 'rgba(0,0,0,0.28)',
222
+ },
223
+ toolbar: {
224
+ position: 'absolute',
225
+ top: 48,
226
+ left: 16,
227
+ right: 16,
228
+ zIndex: 2,
229
+ flexDirection: 'row',
230
+ alignItems: 'center',
231
+ gap: 10,
232
+ padding: 12,
233
+ borderRadius: 14,
234
+ backgroundColor: '#0f172a',
235
+ borderWidth: 1,
236
+ borderColor: 'rgba(255,255,255,0.12)',
237
+ },
238
+ hint: {flex: 1, color: '#e2e8f0', fontSize: 13, lineHeight: 18},
239
+ cancelBtn: {
240
+ paddingHorizontal: 12,
241
+ paddingVertical: 8,
242
+ borderRadius: 10,
243
+ borderWidth: 1,
244
+ borderColor: 'rgba(255,255,255,0.15)',
245
+ },
246
+ cancelText: {color: '#e2e8f0', fontWeight: '600', fontSize: 13},
247
+ selection: {
248
+ position: 'absolute',
249
+ borderWidth: 2,
250
+ borderColor: '#6366f1',
251
+ borderRadius: 2,
252
+ backgroundColor: 'transparent',
253
+ },
254
+ capturingOverlay: {
255
+ ...StyleSheet.absoluteFillObject,
256
+ alignItems: 'center',
257
+ justifyContent: 'center',
258
+ backgroundColor: 'rgba(0,0,0,0.45)',
259
+ },
260
+ capturingText: {color: '#fff', fontSize: 16, fontWeight: '600'},
261
+ });
@@ -0,0 +1,19 @@
1
+ import type {View} from 'react-native';
2
+
3
+ type CaptureTarget = View | null;
4
+
5
+ let captureTarget: CaptureTarget = null;
6
+
7
+ /** Register the app root view used for clip-region screenshots. */
8
+ export function registerClipRegionCaptureTarget(target: CaptureTarget): () => void {
9
+ captureTarget = target;
10
+ return () => {
11
+ if (captureTarget === target) {
12
+ captureTarget = null;
13
+ }
14
+ };
15
+ }
16
+
17
+ export function getClipRegionCaptureTarget(): CaptureTarget {
18
+ return captureTarget;
19
+ }
@@ -0,0 +1,104 @@
1
+ import type {FormFieldDefinition, FormFieldType} from './parseFormFields';
2
+
3
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4
+ const PHONE_RE = /^\+?[0-9\s\-().]{7,20}$/;
5
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
6
+
7
+ export type FormValues = Record<string, string | boolean>;
8
+
9
+ export function getFieldStringValue(
10
+ values: FormValues,
11
+ field: FormFieldDefinition,
12
+ ): string {
13
+ const raw = values[field.id];
14
+ if (field.type === 'checkbox') {
15
+ return raw === true || raw === 'true' ? 'true' : 'false';
16
+ }
17
+ return String(raw ?? '').trim();
18
+ }
19
+
20
+ export function validateFormFields(
21
+ fields: FormFieldDefinition[],
22
+ values: FormValues,
23
+ ): Record<string, string> {
24
+ const errors: Record<string, string> = {};
25
+
26
+ for (const field of fields) {
27
+ const value = getFieldStringValue(values, field);
28
+
29
+ if (field.type === 'checkbox') {
30
+ if (field.required && value !== 'true') {
31
+ errors[field.id] = `${field.label} is required`;
32
+ }
33
+ continue;
34
+ }
35
+
36
+ if (field.required && !value) {
37
+ errors[field.id] = `${field.label} is required`;
38
+ continue;
39
+ }
40
+
41
+ if (!value) {
42
+ continue;
43
+ }
44
+
45
+ const err = validateByType(field.type, value, field.label);
46
+ if (err) {
47
+ errors[field.id] = err;
48
+ }
49
+
50
+ if (
51
+ (field.type === 'select' || field.type === 'radio') &&
52
+ field.options.length > 0
53
+ ) {
54
+ const allowed = field.options.map(o =>
55
+ typeof o === 'string' ? o : o.value,
56
+ );
57
+ if (!allowed.includes(value)) {
58
+ errors[field.id] = `Select a valid ${field.label}`;
59
+ }
60
+ }
61
+ }
62
+
63
+ return errors;
64
+ }
65
+
66
+ function validateByType(
67
+ type: FormFieldType,
68
+ value: string,
69
+ label: string,
70
+ ): string | null {
71
+ switch (type) {
72
+ case 'email':
73
+ return EMAIL_RE.test(value) ? null : `Enter a valid ${label}`;
74
+ case 'phone':
75
+ return PHONE_RE.test(value)
76
+ ? null
77
+ : `Enter a valid phone number for ${label}`;
78
+ case 'number':
79
+ return Number.isFinite(Number(value))
80
+ ? null
81
+ : `${label} must be a number`;
82
+ case 'date':
83
+ if (!DATE_RE.test(value)) {
84
+ return `${label} must be YYYY-MM-DD`;
85
+ }
86
+ return Number.isNaN(Date.parse(value))
87
+ ? `Enter a valid ${label}`
88
+ : null;
89
+ default:
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** Build web-shaped submit payload: Record<id, string>. */
95
+ export function buildFormSubmitPayload(
96
+ fields: FormFieldDefinition[],
97
+ values: FormValues,
98
+ ): Record<string, string> {
99
+ const result: Record<string, string> = {};
100
+ for (const field of fields) {
101
+ result[field.id] = getFieldStringValue(values, field);
102
+ }
103
+ return result;
104
+ }