@webotme/react-native 0.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 +189 -0
- package/package.json +46 -0
- package/src/AutoFlowExecutor.tsx +353 -0
- package/src/client.ts +265 -0
- package/src/index.ts +27 -0
- package/src/launcher.tsx +93 -0
- package/src/provider.tsx +238 -0
- package/src/types.ts +151 -0
- package/src/widget.tsx +647 -0
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# @webotme/react-native
|
|
2
|
+
|
|
3
|
+
Drop-in AI chatbot for React Native and Expo. Same bot, same training and same
|
|
4
|
+
AutoFlow as the WebotMe web widget.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @webotme/react-native react-native-webview
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`react-native-webview` is a peer dependency — Expo apps can get it with
|
|
13
|
+
`npx expo install react-native-webview`.
|
|
14
|
+
|
|
15
|
+
> Requires a development build, not Expo Go, because `react-native-webview` is
|
|
16
|
+
> a native module.
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
Wrap your app once, then show the widget anywhere.
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { WebotMeProvider, WebotMeLauncher } from "@webotme/react-native";
|
|
24
|
+
|
|
25
|
+
export default function App() {
|
|
26
|
+
return (
|
|
27
|
+
<WebotMeProvider
|
|
28
|
+
botId="YOUR_BOT_ID"
|
|
29
|
+
apiBaseUrl="https://your-api.com"
|
|
30
|
+
chatBaseUrl="https://your-frontend.com"
|
|
31
|
+
>
|
|
32
|
+
<RootNavigator />
|
|
33
|
+
</WebotMeProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Floating bubble (handles open/close automatically):
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { WebotMeLauncher } from "@webotme/react-native";
|
|
42
|
+
|
|
43
|
+
<WebotMeLauncher position="bottom-left" icon="💬" />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Inline chat (renders in place, no launcher needed):
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { WebotMeWidget } from "@webotme/react-native";
|
|
50
|
+
|
|
51
|
+
<WebotMeWidget mode="inline" />
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Controlled modal (if you want your own trigger):
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { WebotMeWidget } from "@webotme/react-native";
|
|
58
|
+
import { useState } from "react";
|
|
59
|
+
|
|
60
|
+
function ChatButton() {
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<Pressable onPress={() => setOpen(true)}>
|
|
65
|
+
<Text>Open Chat</Text>
|
|
66
|
+
</Pressable>
|
|
67
|
+
<WebotMeWidget visible={open} onClose={() => setOpen(false)} />
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Config
|
|
74
|
+
|
|
75
|
+
| Prop | Required | Description |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `botId` | yes | Bot ID from the WebotMe dashboard |
|
|
78
|
+
| `apiBaseUrl` | yes | Backend base URL. Never `localhost` on a device |
|
|
79
|
+
| `chatBaseUrl` | no | Frontend that serves `/chat/{botId}`. Falls back to `apiBaseUrl` |
|
|
80
|
+
| `userId` | no | Stable visitor id. Generated if omitted |
|
|
81
|
+
| `theme` | no | `primary`, `background`, `surface`, `text`, `muted` |
|
|
82
|
+
| `welcome` | no | Message shown before the bot replies |
|
|
83
|
+
| `screenContext` | no | Sent with every message so AutoFlow knows the screen |
|
|
84
|
+
| `timeoutMs` | no | Default `30000` |
|
|
85
|
+
| `resetKey` | no | Change it to start a new session |
|
|
86
|
+
|
|
87
|
+
## Widget Props
|
|
88
|
+
|
|
89
|
+
| Prop | Required | Description |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `mode` | no | `"modal"` (default) opens full-screen; `"inline"` renders in place |
|
|
92
|
+
| `variant` | no | `"web"` (default) loads the Vercel chat page; `"native"` uses a native RN chat UI |
|
|
93
|
+
| `visible` | no | Modal only — open when `true` (default `false`) |
|
|
94
|
+
| `onClose` | no | Called when modal closes (modal only) |
|
|
95
|
+
| `title` | no | Modal header title (modal/native) |
|
|
96
|
+
| `theme` | no | Override `primary`, `background`, `surface`, `text`, `muted` |
|
|
97
|
+
| `showClose` | no | Show header close button (modal/native, default `true`) |
|
|
98
|
+
|
|
99
|
+
## AutoFlow
|
|
100
|
+
|
|
101
|
+
Web AutoFlow reads the page DOM. Native apps have no DOM, so send a
|
|
102
|
+
`screenContext` snapshot and the same engine guides your app.
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
<WebotMeProvider
|
|
106
|
+
botId="YOUR_BOT_ID"
|
|
107
|
+
apiBaseUrl="https://your-api.com"
|
|
108
|
+
screenContext={{
|
|
109
|
+
name: "pricing",
|
|
110
|
+
title: "Plans",
|
|
111
|
+
visibleText: ["Free", "Pro"],
|
|
112
|
+
buttons: [{ id: "buy_pro", text: "Buy Pro" }],
|
|
113
|
+
links: [{ id: "faq", text: "FAQ", route: "/faq" }],
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The server answers with `text` plus, when it wants the app to act, an `action`:
|
|
119
|
+
|
|
120
|
+
| Action | Shape |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| navigate | `{ type: "navigate", route, label }` |
|
|
123
|
+
| click | `{ type: "click", targetId, label }` |
|
|
124
|
+
| fill | `{ type: "fill", fieldId, value, label }` |
|
|
125
|
+
| checkout | `{ type: "checkout", url, label }` |
|
|
126
|
+
|
|
127
|
+
## Native API instead of the web view
|
|
128
|
+
|
|
129
|
+
If you want your own UI, skip the WebView and use the client directly.
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
import { useWebotMeChat } from "@webotme/react-native";
|
|
133
|
+
|
|
134
|
+
const { messages, send, sending, error } = useWebotMeChat();
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Or bypass React entirely:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { WebotMeClient } from "@webotme/react-native";
|
|
141
|
+
|
|
142
|
+
const client = new WebotMeClient({ botId, apiBaseUrl });
|
|
143
|
+
await client.discover(); // feature-detect the server
|
|
144
|
+
const { text, action } = await client.send({
|
|
145
|
+
message: "Hello",
|
|
146
|
+
sessionId,
|
|
147
|
+
userId,
|
|
148
|
+
screenContext,
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Errors
|
|
153
|
+
|
|
154
|
+
Every failure is a `WebotMeError` with a stable `code`:
|
|
155
|
+
|
|
156
|
+
`INVALID_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `BOT_NOT_FOUND`,
|
|
157
|
+
`RATE_LIMITED`, `PLAN_LIMIT_REACHED`, `AI_UNAVAILABLE`, `INTERNAL_ERROR`,
|
|
158
|
+
`NETWORK_ERROR`.
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
try {
|
|
162
|
+
await client.send({ message, sessionId, userId });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof WebotMeError && err.code === "PLAN_LIMIT_REACHED") {
|
|
165
|
+
// show the upgrade screen
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Security
|
|
171
|
+
|
|
172
|
+
Never ship an API key in a mobile app — apps can be decompiled. This package
|
|
173
|
+
authenticates with the bot ID only. For server-to-server calls keep the key on
|
|
174
|
+
your own backend and use `POST /api/sdk/chat`.
|
|
175
|
+
|
|
176
|
+
## Endpoints used
|
|
177
|
+
|
|
178
|
+
| Purpose | Endpoint |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| Contract | `GET /api/chatbot/public/{botId}/native-chat/v1` |
|
|
181
|
+
| Chat | `POST /api/chatbot/public/{botId}/native-chat/v1` |
|
|
182
|
+
| Bot config | `GET /api/chatbot/public/{botId}` |
|
|
183
|
+
| Sessions | `GET /api/chatbot/public/{botId}/sessions/{visitorId}` |
|
|
184
|
+
| New session | `POST /api/chatbot/public/{botId}/session/new` |
|
|
185
|
+
| Web chat page | `GET {chatBaseUrl}/chat/{botId}` |
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
UNLICENSED
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webotme/react-native",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Drop-in AI chatbot for React Native and Expo apps. Same bot, training and AutoFlow as the WebotMe web widget.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc --noEmit",
|
|
13
|
+
"prepublishOnly": "npm run build",
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"chatbot",
|
|
18
|
+
"ai",
|
|
19
|
+
"chat",
|
|
20
|
+
"react-native",
|
|
21
|
+
"expo",
|
|
22
|
+
"webotme",
|
|
23
|
+
"autoflow"
|
|
24
|
+
],
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=18",
|
|
28
|
+
"react-native": ">=0.72",
|
|
29
|
+
"react-native-webview": ">=13"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"react-native-webview": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "^19.3.0",
|
|
38
|
+
"react": "^18.3.1",
|
|
39
|
+
"react-native": "^0.76.9",
|
|
40
|
+
"react-native-webview": "^13.17.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"expo-av": "^16.0.8"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import React, { useCallback, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Keyboard,
|
|
5
|
+
Linking,
|
|
6
|
+
Platform,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
TextInput,
|
|
10
|
+
TouchableOpacity,
|
|
11
|
+
View,
|
|
12
|
+
type ViewStyle,
|
|
13
|
+
} from "react-native";
|
|
14
|
+
import type {
|
|
15
|
+
WebotMeAction,
|
|
16
|
+
WebotMePendingWorkflow,
|
|
17
|
+
WebotMeAutoTask,
|
|
18
|
+
WebotMeScreenContext,
|
|
19
|
+
} from "./types";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Props for the AutoFlowExecutor component.
|
|
23
|
+
*/
|
|
24
|
+
export type AutoFlowExecutorProps = {
|
|
25
|
+
/** Navigation function - must be provided by the app using React Navigation. */
|
|
26
|
+
navigate: (routeName: string, params?: Record<string, any>) => void;
|
|
27
|
+
/** Optional: Go back function. */
|
|
28
|
+
goBack?: () => void;
|
|
29
|
+
/** Optional: Push to a specific screen. */
|
|
30
|
+
push?: (routeName: string, params?: Record<string, any>) => void;
|
|
31
|
+
/** Optional: Reset navigation stack. */
|
|
32
|
+
resetRoot?: (routes: Array<{ name: string; params?: Record<string, any> }>) => void;
|
|
33
|
+
/** Current screen context for form filling context. */
|
|
34
|
+
screenContext?: WebotMeScreenContext;
|
|
35
|
+
/** Called when an action is executed. */
|
|
36
|
+
onActionExecuted?: (action: WebotMeAction, success: boolean, error?: string) => void;
|
|
37
|
+
/** Called when a multi-step workflow starts/continues. */
|
|
38
|
+
onWorkflowUpdate?: (workflow: WebotMePendingWorkflow | null) => void;
|
|
39
|
+
/** Theme colors. */
|
|
40
|
+
theme?: {
|
|
41
|
+
primary?: string;
|
|
42
|
+
background?: string;
|
|
43
|
+
surface?: string;
|
|
44
|
+
text?: string;
|
|
45
|
+
muted?: string;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* AutoFlowExecutor - Executes AutoFlow actions returned by the server.
|
|
51
|
+
*
|
|
52
|
+
* Handles: navigate, click, fill_input, checkout
|
|
53
|
+
* Integrates with React Navigation for native navigation.
|
|
54
|
+
*
|
|
55
|
+
* Usage:
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <AutoFlowExecutor
|
|
58
|
+
* navigate={(name, params) => navigation.navigate(name, params)}
|
|
59
|
+
* goBack={() => navigation.goBack()}
|
|
60
|
+
* push={(name, params) => navigation.push(name, params)}
|
|
61
|
+
* onActionExecuted={(action, success, error) => console.log(action, success)}
|
|
62
|
+
* />
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function AutoFlowExecutor({
|
|
66
|
+
navigate,
|
|
67
|
+
goBack,
|
|
68
|
+
push,
|
|
69
|
+
resetRoot,
|
|
70
|
+
screenContext,
|
|
71
|
+
onActionExecuted,
|
|
72
|
+
onWorkflowUpdate,
|
|
73
|
+
theme,
|
|
74
|
+
}: AutoFlowExecutorProps) {
|
|
75
|
+
const palette = {
|
|
76
|
+
primary: theme?.primary || "#4f46e5",
|
|
77
|
+
background: theme?.background || "#0f172a",
|
|
78
|
+
surface: theme?.surface || "#1e293b",
|
|
79
|
+
text: theme?.text || "#f8fafc",
|
|
80
|
+
muted: theme?.muted || "#94a3b8",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const [pendingWorkflow, setPendingWorkflow] = useState<WebotMePendingWorkflow | null>(null);
|
|
84
|
+
const [showFillForm, setShowFillForm] = useState<{
|
|
85
|
+
action: WebotMeAction & { type: "fill" };
|
|
86
|
+
fields: Array<{ label: string; placeholder: string; value: string; name: string; required: boolean; type: string }>;
|
|
87
|
+
} | null>(null);
|
|
88
|
+
const workflowRef = useRef<WebotMeAutoTask | null>(null);
|
|
89
|
+
|
|
90
|
+
const executeAction = useCallback(
|
|
91
|
+
async (action: WebotMeAction): Promise<{ success: boolean; error?: string }> => {
|
|
92
|
+
try {
|
|
93
|
+
switch (action.type) {
|
|
94
|
+
case "navigate": {
|
|
95
|
+
const url = action.data?.url;
|
|
96
|
+
if (!url) throw new Error("Navigate action missing URL");
|
|
97
|
+
|
|
98
|
+
// Try to match with known app routes first
|
|
99
|
+
const routeMap: Record<string, string> = {
|
|
100
|
+
"/dashboard": "Dashboard",
|
|
101
|
+
"/dashboard/create": "CreateChatbot",
|
|
102
|
+
"/dashboard/inbox": "Inbox",
|
|
103
|
+
"/dashboard/settings": "Settings",
|
|
104
|
+
"/dashboard/storage": "Storage",
|
|
105
|
+
"/dashboard/scripts": "Scripts",
|
|
106
|
+
"/plans": "Plans",
|
|
107
|
+
"/login": "Login",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const routeName = routeMap[url] || url.replace(/^\//, "").replace(/\//g, "_") || "Home";
|
|
111
|
+
|
|
112
|
+
if (push) {
|
|
113
|
+
push(routeName, { fromAutoFlow: true, originalUrl: url });
|
|
114
|
+
} else {
|
|
115
|
+
navigate(routeName, { fromAutoFlow: true, originalUrl: url });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { success: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "click": {
|
|
122
|
+
// For native, click actions typically mean triggering a UI element
|
|
123
|
+
// The app should handle this via onActionExecuted or refs
|
|
124
|
+
const selector = action.data?.selector;
|
|
125
|
+
const text = action.data?.text;
|
|
126
|
+
const section = action.data?.section;
|
|
127
|
+
|
|
128
|
+
// Notify app to handle the click (e.g., via refs or internal logic)
|
|
129
|
+
return { success: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "fill": {
|
|
133
|
+
const label = action.data?.label;
|
|
134
|
+
const selector = action.data?.selector;
|
|
135
|
+
const text = action.data?.text;
|
|
136
|
+
const currentValue = action.data?.currentValue;
|
|
137
|
+
|
|
138
|
+
if (!text && text !== "") {
|
|
139
|
+
throw new Error("Fill action missing text value");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// For native, we show a form modal to confirm/edit the fill
|
|
143
|
+
// The actual field filling should be handled by the app via onActionExecuted
|
|
144
|
+
// or by using the screenContext forms
|
|
145
|
+
return { success: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "checkout": {
|
|
149
|
+
const url = action.data?.url;
|
|
150
|
+
if (!url) throw new Error("Checkout action missing URL");
|
|
151
|
+
|
|
152
|
+
// Open in browser for secure payment
|
|
153
|
+
await Linking.openURL(url);
|
|
154
|
+
return { success: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
default: {
|
|
158
|
+
return { success: false, error: `Unknown action type: ${(action as any).type}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
[navigate, push],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const executeActions = useCallback(
|
|
169
|
+
async (actions: WebotMeAction[]): Promise<void> => {
|
|
170
|
+
for (const action of actions) {
|
|
171
|
+
const result = await executeAction(action);
|
|
172
|
+
onActionExecuted?.(action, result.success, result.error);
|
|
173
|
+
if (!result.success) {
|
|
174
|
+
Alert.alert("Action Failed", result.error || "Unknown error");
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
// Small delay between actions for UX
|
|
178
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[executeAction, onActionExecuted],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const handleServerResponse = useCallback(
|
|
185
|
+
async (response: {
|
|
186
|
+
actions?: WebotMeAction[];
|
|
187
|
+
pendingWorkflow?: WebotMePendingWorkflow;
|
|
188
|
+
autoTask?: WebotMeAutoTask;
|
|
189
|
+
reply?: string;
|
|
190
|
+
}) => {
|
|
191
|
+
if (response.actions && response.actions.length > 0) {
|
|
192
|
+
await executeActions(response.actions);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (response.pendingWorkflow) {
|
|
196
|
+
setPendingWorkflow(response.pendingWorkflow);
|
|
197
|
+
workflowRef.current = {
|
|
198
|
+
goal: response.pendingWorkflow.goal,
|
|
199
|
+
plan: response.pendingWorkflow.plan,
|
|
200
|
+
done: response.pendingWorkflow.done,
|
|
201
|
+
};
|
|
202
|
+
onWorkflowUpdate?.(response.pendingWorkflow);
|
|
203
|
+
} else if (response.autoTask) {
|
|
204
|
+
workflowRef.current = response.autoTask;
|
|
205
|
+
onWorkflowUpdate?.({
|
|
206
|
+
goal: response.autoTask.goal,
|
|
207
|
+
plan: response.autoTask.plan,
|
|
208
|
+
done: response.autoTask.done,
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
setPendingWorkflow(null);
|
|
212
|
+
workflowRef.current = null;
|
|
213
|
+
onWorkflowUpdate?.(null);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
[executeActions, onWorkflowUpdate],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Expose execute methods for parent to call
|
|
220
|
+
const executorRef = useRef({
|
|
221
|
+
executeAction,
|
|
222
|
+
executeActions,
|
|
223
|
+
handleServerResponse,
|
|
224
|
+
getPendingWorkflow: () => pendingWorkflow,
|
|
225
|
+
getAutoTask: () => workflowRef.current,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Render fill form modal if needed
|
|
229
|
+
if (showFillForm) {
|
|
230
|
+
const { action, fields } = showFillForm;
|
|
231
|
+
const [values, setValues] = useState(
|
|
232
|
+
fields.map((f) => f.value || ""),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<View style={[styles.modalOverlay, { backgroundColor: "rgba(0,0,0,0.6)" }]}>
|
|
237
|
+
<View style={[styles.modalContent, { backgroundColor: palette.surface }]}>
|
|
238
|
+
<Text style={[styles.modalTitle, { color: palette.text }]}>
|
|
239
|
+
Fill Information
|
|
240
|
+
</Text>
|
|
241
|
+
{fields.map((field, index) => (
|
|
242
|
+
<View key={index} style={styles.formField}>
|
|
243
|
+
<Text style={[styles.fieldLabel, { color: palette.text }]}>
|
|
244
|
+
{field.label} {field.required && <Text style={{ color: palette.primary }}>*</Text>}
|
|
245
|
+
</Text>
|
|
246
|
+
<TextInput
|
|
247
|
+
value={values[index]}
|
|
248
|
+
onChangeText={(v) => {
|
|
249
|
+
const newValues = [...values];
|
|
250
|
+
newValues[index] = v;
|
|
251
|
+
setValues(newValues);
|
|
252
|
+
}}
|
|
253
|
+
placeholder={field.placeholder}
|
|
254
|
+
style={[styles.textInput, { backgroundColor: palette.background, color: palette.text }]}
|
|
255
|
+
keyboardType={field.type === "number" ? "numeric" : "default"}
|
|
256
|
+
/>
|
|
257
|
+
</View>
|
|
258
|
+
))}
|
|
259
|
+
<View style={styles.modalButtons}>
|
|
260
|
+
<TouchableOpacity
|
|
261
|
+
style={[styles.btnCancel, { backgroundColor: palette.surface }]}
|
|
262
|
+
onPress={() => setShowFillForm(null)}
|
|
263
|
+
>
|
|
264
|
+
<Text style={[styles.btnText, { color: palette.muted }]}>Cancel</Text>
|
|
265
|
+
</TouchableOpacity>
|
|
266
|
+
<TouchableOpacity
|
|
267
|
+
style={[styles.btnConfirm, { backgroundColor: palette.primary }]}
|
|
268
|
+
onPress={() => {
|
|
269
|
+
// Execute the fill with confirmed values
|
|
270
|
+
const fillAction: WebotMeAction = {
|
|
271
|
+
type: "fill",
|
|
272
|
+
data: {
|
|
273
|
+
label: action.data?.label,
|
|
274
|
+
selector: action.data?.selector,
|
|
275
|
+
text: values[0],
|
|
276
|
+
currentValue: action.data?.currentValue,
|
|
277
|
+
},
|
|
278
|
+
label: action.label,
|
|
279
|
+
};
|
|
280
|
+
executeAction(fillAction).then((result) => {
|
|
281
|
+
onActionExecuted?.(fillAction, result.success, result.error);
|
|
282
|
+
setShowFillForm(null);
|
|
283
|
+
});
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<Text style={[styles.btnText, { color: "#fff" }]}>Confirm</Text>
|
|
287
|
+
</TouchableOpacity>
|
|
288
|
+
</View>
|
|
289
|
+
</View>
|
|
290
|
+
</View>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// This component doesn't render anything visible - it's a headless executor
|
|
295
|
+
// The actual UI for forms/modals would be in the parent or a separate component
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const styles = StyleSheet.create({
|
|
300
|
+
modalOverlay: {
|
|
301
|
+
flex: 1,
|
|
302
|
+
justifyContent: "center",
|
|
303
|
+
alignItems: "center",
|
|
304
|
+
},
|
|
305
|
+
modalContent: {
|
|
306
|
+
width: "90%",
|
|
307
|
+
maxHeight: "80%",
|
|
308
|
+
borderRadius: 16,
|
|
309
|
+
padding: 20,
|
|
310
|
+
},
|
|
311
|
+
modalTitle: {
|
|
312
|
+
fontSize: 18,
|
|
313
|
+
fontWeight: "700",
|
|
314
|
+
marginBottom: 16,
|
|
315
|
+
textAlign: "center",
|
|
316
|
+
},
|
|
317
|
+
formField: {
|
|
318
|
+
marginBottom: 16,
|
|
319
|
+
},
|
|
320
|
+
fieldLabel: {
|
|
321
|
+
fontSize: 14,
|
|
322
|
+
fontWeight: "600",
|
|
323
|
+
marginBottom: 8,
|
|
324
|
+
},
|
|
325
|
+
textInput: {
|
|
326
|
+
paddingHorizontal: 12,
|
|
327
|
+
paddingVertical: 10,
|
|
328
|
+
borderRadius: 8,
|
|
329
|
+
borderWidth: 1,
|
|
330
|
+
borderColor: "#334155",
|
|
331
|
+
fontSize: 16,
|
|
332
|
+
},
|
|
333
|
+
modalButtons: {
|
|
334
|
+
flexDirection: "row",
|
|
335
|
+
justifyContent: "flex-end",
|
|
336
|
+
gap: 12,
|
|
337
|
+
marginTop: 20,
|
|
338
|
+
},
|
|
339
|
+
btnCancel: {
|
|
340
|
+
paddingHorizontal: 20,
|
|
341
|
+
paddingVertical: 12,
|
|
342
|
+
borderRadius: 8,
|
|
343
|
+
},
|
|
344
|
+
btnConfirm: {
|
|
345
|
+
paddingHorizontal: 20,
|
|
346
|
+
paddingVertical: 12,
|
|
347
|
+
borderRadius: 8,
|
|
348
|
+
},
|
|
349
|
+
btnText: {
|
|
350
|
+
fontSize: 14,
|
|
351
|
+
fontWeight: "600",
|
|
352
|
+
},
|
|
353
|
+
});
|