neoagent 2.4.0 → 2.4.1-beta.10
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/LICENSE +619 -21
- package/README.md +1 -1
- package/extensions/chrome-browser/background.mjs +19 -7
- package/extensions/chrome-browser/icons/icon128.png +0 -0
- package/extensions/chrome-browser/icons/icon16.png +0 -0
- package/extensions/chrome-browser/icons/icon48.png +0 -0
- package/extensions/chrome-browser/icons/logo.svg +12 -0
- package/extensions/chrome-browser/manifest.json +13 -2
- package/extensions/chrome-browser/popup.css +5 -0
- package/extensions/chrome-browser/popup.html +7 -5
- package/extensions/chrome-browser/popup.js +16 -7
- package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +391 -0
- package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
- package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_controller.dart +156 -3
- package/flutter_app/lib/main_devices.dart +485 -119
- package/flutter_app/lib/main_settings.dart +289 -30
- package/flutter_app/lib/src/backend_client.dart +89 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +144 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
- package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
- package/flutter_app/lib/src/stream_renderer.dart +286 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +56 -1
- package/package.json +2 -2
- package/server/guest_agent.js +19 -1
- package/server/http/routes.js +191 -0
- package/server/http/socket.js +1 -1
- package/server/index.js +4 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +73834 -72596
- package/server/routes/browser.js +14 -0
- package/server/routes/browser_extension.js +21 -4
- package/server/routes/desktop.js +10 -0
- package/server/routes/settings.js +4 -0
- package/server/routes/stream.js +187 -0
- package/server/services/ai/tools.js +40 -29
- package/server/services/android/controller.js +41 -2
- package/server/services/browser/controller.js +34 -0
- package/server/services/browser/extension/manifest.js +33 -0
- package/server/services/browser/extension/provider.js +12 -6
- package/server/services/browser/extension/registry.js +188 -18
- package/server/services/desktop/gateway.js +28 -3
- package/server/services/desktop/protocol.js +34 -0
- package/server/services/desktop/provider.js +25 -0
- package/server/services/desktop/registry.js +92 -10
- package/server/services/manager.js +19 -2
- package/server/services/runtime/backends/local-vm.js +6 -0
- package/server/services/runtime/docker-vm-manager.js +26 -3
- package/server/services/runtime/manager.js +36 -5
- package/server/services/runtime/settings.js +17 -0
- package/server/services/streaming/android-stream.js +298 -0
- package/server/services/streaming/browser-stream.js +87 -0
- package/server/services/streaming/stream-hub.js +231 -0
- package/server/services/websocket.js +73 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createBrowserProtocol } from './protocol.mjs';
|
|
2
2
|
import { DEFAULT_SERVER_URL } from './config.mjs';
|
|
3
3
|
|
|
4
|
-
const STORAGE_KEYS = ['serverUrl', 'configuredServerUrl', 'token', 'pairingId', 'pairingSecret', 'approvalUrl', 'status'];
|
|
4
|
+
const STORAGE_KEYS = ['serverUrl', 'configuredServerUrl', 'token', 'pairingId', 'pairingSecret', 'approvalUrl', 'status', 'extensionName'];
|
|
5
5
|
const protocol = createBrowserProtocol(chrome);
|
|
6
6
|
let socket = null;
|
|
7
7
|
let reconnectTimer = null;
|
|
@@ -188,10 +188,12 @@ async function handleSocketMessage(raw) {
|
|
|
188
188
|
async function startPairing(serverUrl) {
|
|
189
189
|
const normalized = await resolveServerUrl(serverUrl);
|
|
190
190
|
if (!normalized) throw new Error('NeoAgent server URL required.');
|
|
191
|
+
const { extensionName } = await getStorage(['extensionName']);
|
|
192
|
+
const nameToUse = String(extensionName || 'Chrome Extension').trim() || 'Chrome Extension';
|
|
191
193
|
const response = await fetchWithTimeout(`${normalized}/api/browser-extension/pairing/request`, {
|
|
192
194
|
method: 'POST',
|
|
193
195
|
headers: { 'content-type': 'application/json' },
|
|
194
|
-
body: JSON.stringify({ extensionName:
|
|
196
|
+
body: JSON.stringify({ extensionName: nameToUse }),
|
|
195
197
|
});
|
|
196
198
|
const payload = await response.json().catch(() => ({}));
|
|
197
199
|
if (!response.ok) throw new Error(payload.error || `Pairing failed: ${response.status}`);
|
|
@@ -212,14 +214,15 @@ async function startPairing(serverUrl) {
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
async function claimPairing() {
|
|
215
|
-
const { serverUrl, pairingId, pairingSecret } = await getStorage(['serverUrl', 'pairingId', 'pairingSecret']);
|
|
217
|
+
const { serverUrl, pairingId, pairingSecret, extensionName } = await getStorage(['serverUrl', 'pairingId', 'pairingSecret', 'extensionName']);
|
|
216
218
|
if (!serverUrl || !pairingId || !pairingSecret) {
|
|
217
219
|
throw new Error('No pending pairing request.');
|
|
218
220
|
}
|
|
221
|
+
const nameToUse = String(extensionName || 'Chrome Extension').trim() || 'Chrome Extension';
|
|
219
222
|
const response = await fetchWithTimeout(`${serverUrl}/api/browser-extension/pairing/${encodeURIComponent(pairingId)}/claim`, {
|
|
220
223
|
method: 'POST',
|
|
221
224
|
headers: { 'content-type': 'application/json' },
|
|
222
|
-
body: JSON.stringify({ pairingSecret, extensionName:
|
|
225
|
+
body: JSON.stringify({ pairingSecret, extensionName: nameToUse }),
|
|
223
226
|
});
|
|
224
227
|
const payload = await response.json().catch(() => ({}));
|
|
225
228
|
if (!response.ok) throw new Error(payload.error || `Claim failed: ${response.status}`);
|
|
@@ -250,12 +253,18 @@ async function checkForUpdates(preferredServerUrl) {
|
|
|
250
253
|
const response = await fetchWithTimeout(`${serverUrl}/api/browser-extension/latest`);
|
|
251
254
|
const latest = await response.json().catch(() => ({}));
|
|
252
255
|
if (!response.ok) throw new Error(latest.error || `Update check failed: ${response.status}`);
|
|
253
|
-
const
|
|
256
|
+
const manifest = chrome.runtime.getManifest();
|
|
257
|
+
const currentVersion = manifest.version;
|
|
258
|
+
const currentVersionName = manifest.version_name || currentVersion;
|
|
259
|
+
const latestVersion = latest.version || currentVersion;
|
|
260
|
+
const latestVersionName = latest.versionName || latestVersion;
|
|
254
261
|
return {
|
|
255
262
|
currentVersion,
|
|
256
|
-
|
|
263
|
+
currentVersionName,
|
|
264
|
+
latestVersion,
|
|
265
|
+
latestVersionName,
|
|
257
266
|
downloadUrl: latest.downloadUrl || `${serverUrl}/api/browser-extension/download`,
|
|
258
|
-
updateAvailable: compareVersions(
|
|
267
|
+
updateAvailable: compareVersions(latestVersion, currentVersion) > 0,
|
|
259
268
|
};
|
|
260
269
|
}
|
|
261
270
|
|
|
@@ -279,6 +288,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
|
279
288
|
return disconnect();
|
|
280
289
|
case 'checkForUpdates':
|
|
281
290
|
return checkForUpdates(message.serverUrl);
|
|
291
|
+
case 'saveExtensionName':
|
|
292
|
+
await setStorage({ extensionName: message.extensionName });
|
|
293
|
+
return { success: true };
|
|
282
294
|
case 'openDownload':
|
|
283
295
|
return openDownload(message.serverUrl);
|
|
284
296
|
case 'getState':
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#8f6d3e"/>
|
|
5
|
+
<stop offset="100%" stop-color="#2f7d6e"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="5.76" y="5.76" width="20.48" height="20.48" rx="6.96" fill="url(#bg)" stroke="#ffffff" stroke-opacity="0.16" stroke-width="1"/>
|
|
9
|
+
<polygon points="16,9.76 9.35,13.12 16,16.48 22.65,13.12" fill="white"/>
|
|
10
|
+
<polyline points="9.35,16.48 16,19.68 22.65,16.48" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
<polyline points="9.35,19.68 16,22.72 22.65,19.68" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
</svg>
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "NeoAgent Browser",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "2.4.1.7",
|
|
5
|
+
"version_name": "2.4.1-beta.7",
|
|
5
6
|
"description": "Connect this Chrome browser to NeoAgent for browser automation.",
|
|
6
7
|
"minimum_chrome_version": "118",
|
|
7
8
|
"permissions": ["debugger", "storage", "tabs"],
|
|
8
9
|
"host_permissions": ["http://*/*", "https://*/*"],
|
|
10
|
+
"icons": {
|
|
11
|
+
"16": "icons/icon16.png",
|
|
12
|
+
"48": "icons/icon48.png",
|
|
13
|
+
"128": "icons/icon128.png"
|
|
14
|
+
},
|
|
9
15
|
"action": {
|
|
10
16
|
"default_title": "NeoAgent Browser",
|
|
11
|
-
"default_popup": "popup.html"
|
|
17
|
+
"default_popup": "popup.html",
|
|
18
|
+
"default_icon": {
|
|
19
|
+
"16": "icons/icon16.png",
|
|
20
|
+
"48": "icons/icon48.png",
|
|
21
|
+
"128": "icons/icon128.png"
|
|
22
|
+
}
|
|
12
23
|
},
|
|
13
24
|
"background": {
|
|
14
25
|
"service_worker": "background.mjs",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<body>
|
|
9
9
|
<main>
|
|
10
10
|
<header>
|
|
11
|
+
<img src="icons/logo.svg" alt="NeoAgent" class="logo">
|
|
11
12
|
<p class="eyebrow">NeoAgent Browser</p>
|
|
12
13
|
<h1>Connect this Chrome</h1>
|
|
13
14
|
<p class="intro">Pair once, then let NeoAgent control this browser when you ask it to.</p>
|
|
@@ -31,15 +32,17 @@
|
|
|
31
32
|
</div>
|
|
32
33
|
</section>
|
|
33
34
|
|
|
34
|
-
<button id="openApp" type="button" class="link-button">Open NeoAgent web page</button>
|
|
35
|
-
|
|
36
35
|
<details id="settings" class="settings">
|
|
37
36
|
<summary>Settings & updates</summary>
|
|
38
37
|
<label>
|
|
39
38
|
Server URL
|
|
40
39
|
<input id="serverUrl" type="url" placeholder="https://neoagent.example.com">
|
|
41
40
|
</label>
|
|
42
|
-
<
|
|
41
|
+
<label style="margin-top: 8px;">
|
|
42
|
+
Extension Name
|
|
43
|
+
<input id="extensionName" type="text" placeholder="e.g. Work Laptop, Personal Mac" value="Chrome Extension">
|
|
44
|
+
</label>
|
|
45
|
+
<div class="settings-actions" style="margin-top: 12px;">
|
|
43
46
|
<button id="checkUpdate" type="button" class="secondary">Check for update</button>
|
|
44
47
|
<button id="download" type="button" class="secondary">Download latest ZIP</button>
|
|
45
48
|
<button id="disconnect" type="button" class="secondary danger">Disconnect</button>
|
|
@@ -47,8 +50,7 @@
|
|
|
47
50
|
<p class="hint">The server URL is usually filled in by the ZIP downloaded from NeoAgent.</p>
|
|
48
51
|
</details>
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
<p id="message" class="message"></p>
|
|
53
|
+
<p id="message" class="message"></p>
|
|
52
54
|
</main>
|
|
53
55
|
<script src="popup.js" type="module"></script>
|
|
54
56
|
</body>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const statusEl = document.querySelector('#status');
|
|
2
2
|
const statusDotEl = document.querySelector('#statusDot');
|
|
3
3
|
const serverUrlEl = document.querySelector('#serverUrl');
|
|
4
|
+
const extensionNameEl = document.querySelector('#extensionName');
|
|
4
5
|
const serverLabelEl = document.querySelector('#serverLabel');
|
|
5
6
|
const messageEl = document.querySelector('#message');
|
|
6
7
|
const settingsEl = document.querySelector('#settings');
|
|
@@ -9,7 +10,6 @@ const flowTitleEl = document.querySelector('#flowTitle');
|
|
|
9
10
|
const flowDescriptionEl = document.querySelector('#flowDescription');
|
|
10
11
|
const primaryActionEl = document.querySelector('#primaryAction');
|
|
11
12
|
const secondaryActionEl = document.querySelector('#secondaryAction');
|
|
12
|
-
const openAppEl = document.querySelector('#openApp');
|
|
13
13
|
const disconnectEl = document.querySelector('#disconnect');
|
|
14
14
|
const checkUpdateEl = document.querySelector('#checkUpdate');
|
|
15
15
|
const downloadEl = document.querySelector('#download');
|
|
@@ -59,7 +59,7 @@ function setBusy(isBusy, label = 'Working...') {
|
|
|
59
59
|
}
|
|
60
60
|
const busy = pendingActions > 0;
|
|
61
61
|
|
|
62
|
-
[primaryActionEl, secondaryActionEl,
|
|
62
|
+
[primaryActionEl, secondaryActionEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
|
|
63
63
|
if (!button || button.hidden) return;
|
|
64
64
|
if (busy) {
|
|
65
65
|
if (!Object.prototype.hasOwnProperty.call(button.dataset, 'wasDisabled')) {
|
|
@@ -99,8 +99,6 @@ function updateFlow() {
|
|
|
99
99
|
const hasToken = Boolean(currentState.token || currentState.tokenId);
|
|
100
100
|
const approvalUrl = currentState.approvalUrl || '';
|
|
101
101
|
|
|
102
|
-
openAppEl.disabled = !hasServerUrl;
|
|
103
|
-
|
|
104
102
|
if (!hasServerUrl) {
|
|
105
103
|
stepLabelEl.textContent = 'Step 1 of 3';
|
|
106
104
|
flowTitleEl.textContent = 'Add your NeoAgent server';
|
|
@@ -217,6 +215,10 @@ async function refresh() {
|
|
|
217
215
|
if (serverUrl && document.activeElement !== serverUrlEl) {
|
|
218
216
|
serverUrlEl.value = serverUrl;
|
|
219
217
|
}
|
|
218
|
+
const extensionName = currentState.extensionName || 'Chrome Extension';
|
|
219
|
+
if (document.activeElement !== extensionNameEl) {
|
|
220
|
+
extensionNameEl.value = extensionName;
|
|
221
|
+
}
|
|
220
222
|
if (!serverUrl) {
|
|
221
223
|
settingsEl.open = true;
|
|
222
224
|
}
|
|
@@ -240,10 +242,17 @@ function bindAsyncClick(element, handler) {
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
serverUrlEl.addEventListener('input', updateFlow);
|
|
245
|
+
extensionNameEl.addEventListener('input', async () => {
|
|
246
|
+
const name = String(extensionNameEl.value || '').trim();
|
|
247
|
+
try {
|
|
248
|
+
await send('saveExtensionName', { extensionName: name });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error('Failed to save extension name', err);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
243
253
|
|
|
244
254
|
bindAsyncClick(primaryActionEl, () => runAction(primaryActionEl.dataset.action));
|
|
245
255
|
bindAsyncClick(secondaryActionEl, () => runAction(secondaryActionEl.dataset.action));
|
|
246
|
-
bindAsyncClick(openAppEl, () => runAction('openApp'));
|
|
247
256
|
bindAsyncClick(disconnectEl, async () => {
|
|
248
257
|
await send('disconnect');
|
|
249
258
|
await refresh();
|
|
@@ -253,8 +262,8 @@ bindAsyncClick(checkUpdateEl, async () => {
|
|
|
253
262
|
const result = await send('checkForUpdates', { serverUrl: effectiveServerUrl() });
|
|
254
263
|
setMessage(
|
|
255
264
|
result.updateAvailable
|
|
256
|
-
? `Update available: ${result.currentVersion} -> ${result.latestVersion}.`
|
|
257
|
-
: `Current version ${result.currentVersion} is up to date.`,
|
|
265
|
+
? `Update available: ${result.currentVersionName || result.currentVersion} -> ${result.latestVersionName || result.latestVersion}.`
|
|
266
|
+
: `Current version ${result.currentVersionName || result.currentVersion} is up to date.`,
|
|
258
267
|
result.updateAvailable ? '' : 'success',
|
|
259
268
|
);
|
|
260
269
|
});
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:flutter_animate/flutter_animate.dart';
|
|
3
|
+
import 'package:url_launcher/url_launcher.dart';
|
|
4
|
+
|
|
5
|
+
import '../../main.dart';
|
|
6
|
+
import 'onboarding_chrome.dart';
|
|
7
|
+
|
|
8
|
+
class OnboardingCompanionStep extends StatefulWidget {
|
|
9
|
+
const OnboardingCompanionStep({
|
|
10
|
+
super.key,
|
|
11
|
+
required this.onNext,
|
|
12
|
+
required this.controller,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
final VoidCallback onNext;
|
|
16
|
+
final NeoAgentController controller;
|
|
17
|
+
|
|
18
|
+
@override
|
|
19
|
+
State<OnboardingCompanionStep> createState() => _OnboardingCompanionStepState();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
|
|
23
|
+
final Set<String> _clickedDownloads = <String>{};
|
|
24
|
+
|
|
25
|
+
Future<void> _launchUrl(String urlString) async {
|
|
26
|
+
final url = Uri.parse(urlString);
|
|
27
|
+
if (await canLaunchUrl(url)) {
|
|
28
|
+
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
Widget build(BuildContext context) {
|
|
34
|
+
final width = MediaQuery.sizeOf(context).width;
|
|
35
|
+
final useGrid = width >= 700;
|
|
36
|
+
final columns = width >= 1050 ? 3 : (useGrid ? 2 : 1);
|
|
37
|
+
|
|
38
|
+
return AnimatedBuilder(
|
|
39
|
+
animation: widget.controller,
|
|
40
|
+
builder: (context, _) {
|
|
41
|
+
final extConnected = widget.controller.browserExtensionConnected;
|
|
42
|
+
final desktopConnected = widget.controller.desktopCompanionConnected;
|
|
43
|
+
|
|
44
|
+
final items = <_CompanionItemData>[
|
|
45
|
+
_CompanionItemData(
|
|
46
|
+
id: 'extension',
|
|
47
|
+
title: 'Chrome Extension',
|
|
48
|
+
subtitle: 'Automate browser tasks and capture web page context directly.',
|
|
49
|
+
icon: Icons.extension_rounded,
|
|
50
|
+
accentColor: extConnected ? const Color(0xFF10A37F) : const Color(0xFF4285F4),
|
|
51
|
+
connected: extConnected,
|
|
52
|
+
buttonText: extConnected ? 'Extension Connected' : 'Download Extension',
|
|
53
|
+
onTap: () async {
|
|
54
|
+
setState(() => _clickedDownloads.add('extension'));
|
|
55
|
+
await widget.controller.downloadBrowserExtension();
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
_CompanionItemData(
|
|
59
|
+
id: 'desktop',
|
|
60
|
+
title: 'Desktop App',
|
|
61
|
+
subtitle: 'Enable native command run, system capture, and global controls.',
|
|
62
|
+
icon: Icons.laptop_mac_rounded,
|
|
63
|
+
accentColor: desktopConnected ? const Color(0xFF10A37F) : const Color(0xFF8A5CF5),
|
|
64
|
+
connected: desktopConnected,
|
|
65
|
+
buttonText: desktopConnected ? 'Desktop Connected' : 'Get Desktop App',
|
|
66
|
+
onTap: () async {
|
|
67
|
+
setState(() => _clickedDownloads.add('desktop'));
|
|
68
|
+
await _launchUrl('https://github.com/NeoLabs-Systems/NeoAgent/releases');
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
_CompanionItemData(
|
|
72
|
+
id: 'mobile',
|
|
73
|
+
title: 'Mobile Companion',
|
|
74
|
+
subtitle: 'Sync notifications, phone calls, and health connect metrics.',
|
|
75
|
+
icon: Icons.phone_iphone_rounded,
|
|
76
|
+
accentColor: const Color(0xFFF5A623),
|
|
77
|
+
connected: false,
|
|
78
|
+
buttonText: 'Download Android APK',
|
|
79
|
+
onTap: () async {
|
|
80
|
+
setState(() => _clickedDownloads.add('mobile'));
|
|
81
|
+
await _launchUrl('https://github.com/NeoLabs-Systems/NeoAgent/releases');
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
return OnboardingScaffold(
|
|
87
|
+
step: 1,
|
|
88
|
+
totalSteps: 4,
|
|
89
|
+
eyebrow: 'INTEGRATION',
|
|
90
|
+
title: 'Connect your\ndevices & apps.',
|
|
91
|
+
description: 'NeoOS works best when integrated with your desktop, browser, and mobile devices.',
|
|
92
|
+
footer: Row(
|
|
93
|
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
94
|
+
children: <Widget>[
|
|
95
|
+
OnboardingGhostButton(
|
|
96
|
+
label: 'Skip integration',
|
|
97
|
+
onPressed: widget.onNext,
|
|
98
|
+
),
|
|
99
|
+
OnboardingPrimaryButton(
|
|
100
|
+
label: 'Continue',
|
|
101
|
+
icon: Icons.arrow_forward_rounded,
|
|
102
|
+
onPressed: widget.onNext,
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
),
|
|
106
|
+
child: useGrid
|
|
107
|
+
? GridView.builder(
|
|
108
|
+
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
109
|
+
crossAxisCount: columns,
|
|
110
|
+
crossAxisSpacing: 14,
|
|
111
|
+
mainAxisSpacing: 14,
|
|
112
|
+
childAspectRatio: width >= 1050 ? 1.42 : 1.25,
|
|
113
|
+
),
|
|
114
|
+
itemCount: items.length,
|
|
115
|
+
itemBuilder: (context, index) {
|
|
116
|
+
final item = items[index];
|
|
117
|
+
return _CompanionCard(
|
|
118
|
+
item: item,
|
|
119
|
+
compact: true,
|
|
120
|
+
isClicked: _clickedDownloads.contains(item.id),
|
|
121
|
+
)
|
|
122
|
+
.animate()
|
|
123
|
+
.fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
|
|
124
|
+
.slideY(begin: 0.16, end: 0);
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
: ListView.separated(
|
|
128
|
+
itemCount: items.length,
|
|
129
|
+
separatorBuilder: (_, __) => const SizedBox(height: 14),
|
|
130
|
+
itemBuilder: (context, index) {
|
|
131
|
+
final item = items[index];
|
|
132
|
+
return _CompanionCard(
|
|
133
|
+
item: item,
|
|
134
|
+
compact: false,
|
|
135
|
+
isClicked: _clickedDownloads.contains(item.id),
|
|
136
|
+
)
|
|
137
|
+
.animate()
|
|
138
|
+
.fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
|
|
139
|
+
.slideY(begin: 0.16, end: 0);
|
|
140
|
+
},
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
class _CompanionItemData {
|
|
149
|
+
const _CompanionItemData({
|
|
150
|
+
required this.id,
|
|
151
|
+
required this.title,
|
|
152
|
+
required this.subtitle,
|
|
153
|
+
required this.icon,
|
|
154
|
+
required this.accentColor,
|
|
155
|
+
required this.connected,
|
|
156
|
+
required this.buttonText,
|
|
157
|
+
required this.onTap,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
final String id;
|
|
161
|
+
final String title;
|
|
162
|
+
final String subtitle;
|
|
163
|
+
final IconData icon;
|
|
164
|
+
final Color accentColor;
|
|
165
|
+
final bool connected;
|
|
166
|
+
final String buttonText;
|
|
167
|
+
final VoidCallback onTap;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class _CompanionCard extends StatelessWidget {
|
|
171
|
+
const _CompanionCard({
|
|
172
|
+
required this.item,
|
|
173
|
+
required this.compact,
|
|
174
|
+
required this.isClicked,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
final _CompanionItemData item;
|
|
178
|
+
final bool compact;
|
|
179
|
+
final bool isClicked;
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
Widget build(BuildContext context) {
|
|
183
|
+
final shellSize = compact ? 48.0 : 58.0;
|
|
184
|
+
final iconSize = compact ? 24.0 : 30.0;
|
|
185
|
+
|
|
186
|
+
final cardContent = compact
|
|
187
|
+
? Column(
|
|
188
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
189
|
+
children: <Widget>[
|
|
190
|
+
Row(
|
|
191
|
+
children: <Widget>[
|
|
192
|
+
Container(
|
|
193
|
+
width: shellSize,
|
|
194
|
+
height: shellSize,
|
|
195
|
+
decoration: BoxDecoration(
|
|
196
|
+
color: item.accentColor.withValues(alpha: 0.18),
|
|
197
|
+
borderRadius: BorderRadius.circular(16),
|
|
198
|
+
),
|
|
199
|
+
child: Icon(
|
|
200
|
+
item.icon,
|
|
201
|
+
color: item.accentColor,
|
|
202
|
+
size: iconSize,
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
const Spacer(),
|
|
206
|
+
_StatusIndicator(connected: item.connected, color: item.accentColor),
|
|
207
|
+
],
|
|
208
|
+
),
|
|
209
|
+
const SizedBox(height: 14),
|
|
210
|
+
Text(
|
|
211
|
+
item.title,
|
|
212
|
+
maxLines: 1,
|
|
213
|
+
overflow: TextOverflow.ellipsis,
|
|
214
|
+
style: const TextStyle(
|
|
215
|
+
color: Colors.white,
|
|
216
|
+
fontSize: 18,
|
|
217
|
+
fontWeight: FontWeight.w800,
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
const SizedBox(height: 6),
|
|
221
|
+
Expanded(
|
|
222
|
+
child: Text(
|
|
223
|
+
item.subtitle,
|
|
224
|
+
maxLines: 3,
|
|
225
|
+
overflow: TextOverflow.ellipsis,
|
|
226
|
+
style: TextStyle(
|
|
227
|
+
color: Colors.white.withValues(alpha: 0.68),
|
|
228
|
+
fontSize: 13,
|
|
229
|
+
height: 1.35,
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
const SizedBox(height: 12),
|
|
234
|
+
_DownloadButton(item: item, isClicked: isClicked),
|
|
235
|
+
],
|
|
236
|
+
)
|
|
237
|
+
: Row(
|
|
238
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
239
|
+
children: <Widget>[
|
|
240
|
+
Container(
|
|
241
|
+
width: shellSize,
|
|
242
|
+
height: shellSize,
|
|
243
|
+
decoration: BoxDecoration(
|
|
244
|
+
color: item.accentColor.withValues(alpha: 0.18),
|
|
245
|
+
borderRadius: BorderRadius.circular(18),
|
|
246
|
+
),
|
|
247
|
+
child: Icon(
|
|
248
|
+
item.icon,
|
|
249
|
+
color: item.accentColor,
|
|
250
|
+
size: iconSize,
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
const SizedBox(width: 18),
|
|
254
|
+
Expanded(
|
|
255
|
+
child: Column(
|
|
256
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
257
|
+
mainAxisSize: MainAxisSize.min,
|
|
258
|
+
children: <Widget>[
|
|
259
|
+
Text(
|
|
260
|
+
item.title,
|
|
261
|
+
style: const TextStyle(
|
|
262
|
+
color: Colors.white,
|
|
263
|
+
fontSize: 20,
|
|
264
|
+
fontWeight: FontWeight.w800,
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
const SizedBox(height: 5),
|
|
268
|
+
Text(
|
|
269
|
+
item.subtitle,
|
|
270
|
+
style: TextStyle(
|
|
271
|
+
color: Colors.white.withValues(alpha: 0.68),
|
|
272
|
+
fontSize: 14,
|
|
273
|
+
height: 1.45,
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
const SizedBox(height: 14),
|
|
277
|
+
_DownloadButton(item: item, isClicked: isClicked),
|
|
278
|
+
],
|
|
279
|
+
),
|
|
280
|
+
),
|
|
281
|
+
const SizedBox(width: 12),
|
|
282
|
+
_StatusIndicator(connected: item.connected, color: item.accentColor),
|
|
283
|
+
],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return OnboardingOptionCard(
|
|
287
|
+
selected: item.connected,
|
|
288
|
+
accent: item.accentColor,
|
|
289
|
+
compact: compact,
|
|
290
|
+
onTap: item.onTap,
|
|
291
|
+
child: cardContent,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
class _StatusIndicator extends StatelessWidget {
|
|
297
|
+
const _StatusIndicator({required this.connected, required this.color});
|
|
298
|
+
|
|
299
|
+
final bool connected;
|
|
300
|
+
final Color color;
|
|
301
|
+
|
|
302
|
+
@override
|
|
303
|
+
Widget build(BuildContext context) {
|
|
304
|
+
return AnimatedSwitcher(
|
|
305
|
+
duration: const Duration(milliseconds: 220),
|
|
306
|
+
child: connected
|
|
307
|
+
? Icon(
|
|
308
|
+
Icons.check_circle_rounded,
|
|
309
|
+
key: const ValueKey<String>('connected'),
|
|
310
|
+
color: color,
|
|
311
|
+
size: 28,
|
|
312
|
+
)
|
|
313
|
+
: Icon(
|
|
314
|
+
Icons.arrow_circle_down_rounded,
|
|
315
|
+
key: const ValueKey<String>('downloadable'),
|
|
316
|
+
color: Colors.white.withValues(alpha: 0.26),
|
|
317
|
+
size: 28,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
class _DownloadButton extends StatelessWidget {
|
|
324
|
+
const _DownloadButton({required this.item, required this.isClicked});
|
|
325
|
+
|
|
326
|
+
final _CompanionItemData item;
|
|
327
|
+
final bool isClicked;
|
|
328
|
+
|
|
329
|
+
@override
|
|
330
|
+
Widget build(BuildContext context) {
|
|
331
|
+
if (item.connected) {
|
|
332
|
+
return Container(
|
|
333
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
334
|
+
decoration: BoxDecoration(
|
|
335
|
+
color: item.accentColor.withValues(alpha: 0.16),
|
|
336
|
+
borderRadius: BorderRadius.circular(12),
|
|
337
|
+
border: Border.all(color: item.accentColor.withValues(alpha: 0.3)),
|
|
338
|
+
),
|
|
339
|
+
child: Row(
|
|
340
|
+
mainAxisSize: MainAxisSize.min,
|
|
341
|
+
children: <Widget>[
|
|
342
|
+
Icon(Icons.done_all_rounded, size: 14, color: item.accentColor),
|
|
343
|
+
const SizedBox(width: 6),
|
|
344
|
+
Text(
|
|
345
|
+
'Connected',
|
|
346
|
+
style: TextStyle(
|
|
347
|
+
color: item.accentColor,
|
|
348
|
+
fontSize: 12,
|
|
349
|
+
fontWeight: FontWeight.w700,
|
|
350
|
+
),
|
|
351
|
+
),
|
|
352
|
+
],
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return Container(
|
|
358
|
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
359
|
+
decoration: BoxDecoration(
|
|
360
|
+
color: isClicked
|
|
361
|
+
? Colors.white.withValues(alpha: 0.08)
|
|
362
|
+
: item.accentColor.withValues(alpha: 0.12),
|
|
363
|
+
borderRadius: BorderRadius.circular(12),
|
|
364
|
+
border: Border.all(
|
|
365
|
+
color: isClicked
|
|
366
|
+
? Colors.white.withValues(alpha: 0.2)
|
|
367
|
+
: item.accentColor.withValues(alpha: 0.25),
|
|
368
|
+
),
|
|
369
|
+
),
|
|
370
|
+
child: Row(
|
|
371
|
+
mainAxisSize: MainAxisSize.min,
|
|
372
|
+
children: <Widget>[
|
|
373
|
+
Icon(
|
|
374
|
+
isClicked ? Icons.hourglass_empty_rounded : Icons.open_in_new_rounded,
|
|
375
|
+
size: 14,
|
|
376
|
+
color: isClicked ? Colors.white70 : item.accentColor,
|
|
377
|
+
),
|
|
378
|
+
const SizedBox(width: 6),
|
|
379
|
+
Text(
|
|
380
|
+
isClicked ? 'Waiting for pairing...' : item.buttonText,
|
|
381
|
+
style: TextStyle(
|
|
382
|
+
color: isClicked ? Colors.white70 : Colors.white,
|
|
383
|
+
fontSize: 12,
|
|
384
|
+
fontWeight: FontWeight.w700,
|
|
385
|
+
),
|
|
386
|
+
),
|
|
387
|
+
],
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|
|
2
2
|
import '../../main.dart';
|
|
3
3
|
import 'onboarding_video_step.dart';
|
|
4
4
|
import 'onboarding_welcome_step.dart';
|
|
5
|
+
import 'onboarding_companion_step.dart';
|
|
5
6
|
import 'onboarding_messaging_step.dart';
|
|
6
7
|
import 'onboarding_model_step.dart';
|
|
7
8
|
|
|
@@ -45,6 +46,10 @@ class _OnboardingShellState extends State<OnboardingShell> {
|
|
|
45
46
|
children: <Widget>[
|
|
46
47
|
OnboardingVideoStep(onComplete: _nextStep),
|
|
47
48
|
OnboardingWelcomeStep(onNext: _nextStep),
|
|
49
|
+
OnboardingCompanionStep(
|
|
50
|
+
onNext: _nextStep,
|
|
51
|
+
controller: widget.controller,
|
|
52
|
+
),
|
|
48
53
|
OnboardingMessagingStep(
|
|
49
54
|
onNext: _nextStep,
|
|
50
55
|
controller: widget.controller,
|
|
@@ -55,3 +60,4 @@ class _OnboardingShellState extends State<OnboardingShell> {
|
|
|
55
60
|
);
|
|
56
61
|
}
|
|
57
62
|
}
|
|
63
|
+
|
|
@@ -39,6 +39,7 @@ import 'src/messaging_access_summary.dart';
|
|
|
39
39
|
import 'src/oauth_launcher.dart';
|
|
40
40
|
import 'src/recording_bridge.dart';
|
|
41
41
|
import 'src/recording_payloads.dart';
|
|
42
|
+
import 'src/stream_renderer.dart';
|
|
42
43
|
import 'src/theme/palette.dart';
|
|
43
44
|
import 'src/web_app_update_monitor.dart';
|
|
44
45
|
import 'src/widget_bridge.dart';
|