neoagent 2.3.1-beta.4 → 2.3.1-beta.41

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 (290) hide show
  1. package/.env.example +45 -0
  2. package/docs/capabilities.md +2 -2
  3. package/docs/configuration.md +12 -5
  4. package/docs/hardware.md +1 -1
  5. package/docs/integrations.md +2 -3
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +109 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  30. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  31. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  32. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  33. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  35. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  36. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  37. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  38. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  39. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  43. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  44. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  45. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  46. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  48. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  49. package/flutter_app/android/build.gradle.kts +24 -0
  50. package/flutter_app/android/ci-release.keystore +0 -0
  51. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  52. package/flutter_app/android/gradle.properties +3 -0
  53. package/flutter_app/android/key.properties +4 -0
  54. package/flutter_app/android/settings.gradle.kts +26 -0
  55. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  57. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  58. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  59. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  60. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  62. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  63. package/flutter_app/lib/features/location/location_service.dart +119 -0
  64. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  65. package/flutter_app/lib/main.dart +99 -0
  66. package/flutter_app/lib/main_account_settings.dart +1250 -0
  67. package/flutter_app/lib/main_admin.dart +886 -0
  68. package/flutter_app/lib/main_app_shell.dart +1682 -0
  69. package/flutter_app/lib/main_chat.dart +3352 -0
  70. package/flutter_app/lib/main_controller.dart +6781 -0
  71. package/flutter_app/lib/main_devices.dart +2301 -0
  72. package/flutter_app/lib/main_integrations.dart +1129 -0
  73. package/flutter_app/lib/main_launcher.dart +959 -0
  74. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  75. package/flutter_app/lib/main_models.dart +3546 -0
  76. package/flutter_app/lib/main_navigation.dart +193 -0
  77. package/flutter_app/lib/main_operations.dart +4851 -0
  78. package/flutter_app/lib/main_recordings.dart +870 -0
  79. package/flutter_app/lib/main_runtime.dart +806 -0
  80. package/flutter_app/lib/main_settings.dart +2024 -0
  81. package/flutter_app/lib/main_shared.dart +2861 -0
  82. package/flutter_app/lib/main_theme.dart +204 -0
  83. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  84. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  85. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  86. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  87. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  88. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  89. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  90. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  91. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  92. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  93. package/flutter_app/lib/src/backend_client.dart +1833 -0
  94. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  95. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  96. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  97. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  98. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  99. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  100. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  101. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  102. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  103. package/flutter_app/lib/src/health_bridge.dart +136 -0
  104. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  105. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  106. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  107. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  108. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  109. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  110. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  111. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  112. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  113. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  114. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  115. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  116. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  117. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  118. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  119. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  120. package/flutter_app/lib/src/theme/palette.dart +81 -0
  121. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  122. package/flutter_app/linux/CMakeLists.txt +128 -0
  123. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  124. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  125. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  126. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  127. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  128. package/flutter_app/linux/runner/main.cc +6 -0
  129. package/flutter_app/linux/runner/my_application.cc +144 -0
  130. package/flutter_app/linux/runner/my_application.h +18 -0
  131. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  132. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  133. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  134. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  135. package/flutter_app/macos/Podfile +42 -0
  136. package/flutter_app/macos/Podfile.lock +87 -0
  137. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  138. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  139. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  140. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  141. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  142. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  143. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  144. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  145. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  146. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  147. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  148. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  149. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  150. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  151. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  152. package/flutter_app/macos/Runner/Info.plist +36 -0
  153. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  154. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  155. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  156. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  157. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  158. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  159. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  160. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  161. package/flutter_app/patch_strings.py +12 -0
  162. package/flutter_app/pubspec.lock +1088 -0
  163. package/flutter_app/pubspec.yaml +53 -0
  164. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  165. package/flutter_app/test/recording_payloads_test.dart +53 -0
  166. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  167. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  168. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  169. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  170. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  171. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  172. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  173. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  174. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  175. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  176. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  177. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  178. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  179. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  180. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  181. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  182. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  183. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  184. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  185. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  186. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  187. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  188. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  189. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  190. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  191. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  192. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  193. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  194. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  195. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  196. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  197. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  198. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  199. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  200. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  201. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  202. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  203. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  204. package/flutter_app/web/favicon.png +0 -0
  205. package/flutter_app/web/favicon.svg +12 -0
  206. package/flutter_app/web/icons/Icon-192.png +0 -0
  207. package/flutter_app/web/icons/Icon-512.png +0 -0
  208. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  209. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  210. package/flutter_app/web/index.html +39 -0
  211. package/flutter_app/web/manifest.json +35 -0
  212. package/flutter_app/windows/CMakeLists.txt +108 -0
  213. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  214. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  215. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  216. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  217. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  218. package/flutter_app/windows/runner/Runner.rc +121 -0
  219. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  220. package/flutter_app/windows/runner/flutter_window.h +37 -0
  221. package/flutter_app/windows/runner/main.cpp +53 -0
  222. package/flutter_app/windows/runner/resource.h +16 -0
  223. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  224. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  225. package/flutter_app/windows/runner/utils.cpp +65 -0
  226. package/flutter_app/windows/runner/utils.h +19 -0
  227. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  228. package/flutter_app/windows/runner/win32_window.h +102 -0
  229. package/lib/install_helpers.js +31 -0
  230. package/lib/manager.js +227 -6
  231. package/package.json +3 -1
  232. package/server/db/database.js +110 -0
  233. package/server/http/middleware.js +55 -2
  234. package/server/http/routes.js +1 -0
  235. package/server/index.js +3 -0
  236. package/server/public/.last_build_id +1 -1
  237. package/server/public/assets/NOTICES +1 -1
  238. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  239. package/server/public/canvaskit/wimp.wasm +0 -0
  240. package/server/public/flutter_bootstrap.js +2 -2
  241. package/server/public/main.dart.js +74324 -73132
  242. package/server/routes/integrations.js +108 -1
  243. package/server/routes/memory.js +11 -2
  244. package/server/{http/routes → routes}/screenHistory.js +2 -2
  245. package/server/routes/settings.js +75 -2
  246. package/server/{http/routes → routes}/triggers.js +2 -2
  247. package/server/routes/wearable.js +67 -0
  248. package/server/services/ai/models.js +30 -0
  249. package/server/services/ai/providers/githubCopilot.js +97 -0
  250. package/server/services/ai/providers/openai.js +2 -1
  251. package/server/services/ai/providers/openaiCodex.js +31 -0
  252. package/server/services/ai/settings.js +20 -0
  253. package/server/services/ai/toolSelector.js +14 -1
  254. package/server/services/ai/tools.js +77 -4
  255. package/server/services/desktop/screenRecorder.js +65 -9
  256. package/server/services/integrations/env.js +5 -0
  257. package/server/services/integrations/figma/provider.js +1 -0
  258. package/server/services/integrations/github/common.js +106 -0
  259. package/server/services/integrations/github/provider.js +499 -0
  260. package/server/services/integrations/github/repos.js +1124 -0
  261. package/server/services/integrations/google/provider.js +1 -0
  262. package/server/services/integrations/home_assistant/provider.js +325 -26
  263. package/server/services/integrations/manager.js +88 -12
  264. package/server/services/integrations/microsoft/provider.js +1 -0
  265. package/server/services/integrations/oauth_provider.js +25 -8
  266. package/server/services/integrations/provider_config_store.js +85 -0
  267. package/server/services/integrations/registry.js +4 -0
  268. package/server/services/integrations/spotify/provider.js +1 -0
  269. package/server/services/integrations/trello/provider.js +842 -0
  270. package/server/services/manager.js +46 -1
  271. package/server/services/mcp/client.js +120 -23
  272. package/server/services/memory/manager.js +39 -2
  273. package/server/services/messaging/access_policy.js +10 -0
  274. package/server/services/messaging/manager.js +49 -0
  275. package/server/services/messaging/meshtastic.js +260 -0
  276. package/server/services/messaging/meshtastic_env.js +100 -0
  277. package/server/services/messaging/meshtastic_protocol.js +476 -0
  278. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  279. package/server/services/tasks/runtime.js +1 -1
  280. package/server/services/voice/openaiClient.js +4 -1
  281. package/server/services/voice/openaiSpeech.js +6 -1
  282. package/server/services/voice/providers.js +52 -12
  283. package/server/services/voice/runtimeManager.js +136 -19
  284. package/server/services/voice/turnRunner.js +29 -9
  285. package/server/services/wearable/firmware_manifest.js +370 -0
  286. package/server/services/wearable/gateway.js +350 -0
  287. package/server/services/wearable/protocol.js +45 -0
  288. package/server/services/wearable/service.js +244 -0
  289. package/server/utils/local_secrets.js +56 -0
  290. package/server/utils/logger.js +37 -9
@@ -0,0 +1,2301 @@
1
+ part of 'main.dart';
2
+
3
+ class _OptimizedScreenshotPayload {
4
+ const _OptimizedScreenshotPayload({
5
+ required this.bytes,
6
+ required this.mimeType,
7
+ required this.resizedOrReencoded,
8
+ });
9
+
10
+ final Uint8List bytes;
11
+ final String mimeType;
12
+ final bool resizedOrReencoded;
13
+ }
14
+
15
+ class DevicesPanel extends StatefulWidget {
16
+ const DevicesPanel({super.key, required this.controller});
17
+
18
+ final NeoAgentController controller;
19
+
20
+ @override
21
+ State<DevicesPanel> createState() => _DevicesPanelState();
22
+ }
23
+
24
+ class _DevicesPanelState extends State<DevicesPanel> {
25
+ late final TextEditingController _browserUrlController;
26
+ late final TextEditingController _androidLaunchController;
27
+ late final TextEditingController _desktopLaunchController;
28
+ late final TextEditingController _textEntryController;
29
+ Timer? _surfaceFrameTimer;
30
+ _DeviceSurface _surface = _DeviceSurface.browser;
31
+
32
+ @override
33
+ void initState() {
34
+ super.initState();
35
+ _browserUrlController = TextEditingController(text: _browserUrlPlaceholder);
36
+ _androidLaunchController = TextEditingController(
37
+ text: _androidLaunchPlaceholder,
38
+ );
39
+ _desktopLaunchController = TextEditingController();
40
+ _textEntryController = TextEditingController();
41
+ WidgetsBinding.instance.addPostFrameCallback((_) {
42
+ if (!mounted) {
43
+ return;
44
+ }
45
+ unawaited(_bootstrapSurface());
46
+ });
47
+ _surfaceFrameTimer = Timer.periodic(const Duration(seconds: 3), (_) {
48
+ unawaited(_refreshSurfaceFrame());
49
+ });
50
+ }
51
+
52
+ @override
53
+ void dispose() {
54
+ for (final controller in <TextEditingController>[
55
+ _browserUrlController,
56
+ _androidLaunchController,
57
+ _desktopLaunchController,
58
+ _textEntryController,
59
+ ]) {
60
+ controller.dispose();
61
+ }
62
+ _surfaceFrameTimer?.cancel();
63
+ super.dispose();
64
+ }
65
+
66
+ bool get _isBrowser => _surface == _DeviceSurface.browser;
67
+ bool get _isDesktop => _surface == _DeviceSurface.desktop;
68
+
69
+ bool get _androidOnline {
70
+ final status = widget.controller.androidRuntime;
71
+ final devices = _jsonMapList(status['devices'], fallbackToMapValues: true);
72
+ return devices.any((device) => device['status']?.toString() == 'device');
73
+ }
74
+
75
+ bool get _androidStarting =>
76
+ widget.controller.androidRuntime['starting'] == true;
77
+
78
+ List<Map<String, dynamic>> get _onlineDesktopDevices => widget
79
+ .controller
80
+ .desktopDevices
81
+ .where((device) => device['online'] == true)
82
+ .toList(growable: false);
83
+
84
+ bool get _desktopOnline => _onlineDesktopDevices.isNotEmpty;
85
+
86
+ bool get _desktopRequiresSelection =>
87
+ _isDesktop &&
88
+ _onlineDesktopDevices.length > 1 &&
89
+ (widget.controller.selectedDesktopDeviceId ?? '').isEmpty;
90
+
91
+ String? get _activeScreenshotPath {
92
+ if (_isBrowser) {
93
+ return widget.controller.browserScreenshotPath;
94
+ }
95
+ if (_isDesktop) {
96
+ return widget.controller.desktopScreenshotPath;
97
+ }
98
+ return widget.controller.androidScreenshotPath;
99
+ }
100
+
101
+ Future<void> _bootstrapSurface() async {
102
+ await widget.controller.refreshDevices();
103
+ await _ensurePreview();
104
+ }
105
+
106
+ Future<void> _ensurePreview() async {
107
+ final controller = widget.controller;
108
+ if (_isBrowser) {
109
+ if (controller.browserRuntime['launched'] != true) {
110
+ return;
111
+ }
112
+ if ((controller.browserScreenshotPath ?? '').isEmpty) {
113
+ final currentUrl =
114
+ controller.browserRuntime['pageInfo'] is Map<dynamic, dynamic>
115
+ ? (controller.browserRuntime['pageInfo'] as Map)['url']?.toString()
116
+ : null;
117
+ if (currentUrl != null && currentUrl.isNotEmpty) {
118
+ await controller.navigateBrowserRuntime(url: currentUrl);
119
+ } else {
120
+ await controller.screenshotBrowserRuntime();
121
+ }
122
+ }
123
+ return;
124
+ }
125
+
126
+ if (_isDesktop) {
127
+ if (_desktopRequiresSelection || !_desktopOnline) {
128
+ return;
129
+ }
130
+ if ((controller.desktopScreenshotPath ?? '').isEmpty) {
131
+ await controller.screenshotDesktopRuntime();
132
+ }
133
+ return;
134
+ }
135
+
136
+ if (_androidOnline && (controller.androidScreenshotPath ?? '').isEmpty) {
137
+ await controller.screenshotAndroidRuntime();
138
+ }
139
+ }
140
+
141
+ Future<void> _refreshSurfaceFrame() async {
142
+ if (!mounted ||
143
+ widget.controller.selectedSection != AppSection.devices ||
144
+ widget.controller.isRunningDeviceAction ||
145
+ widget.controller.isRefreshingDevices) {
146
+ return;
147
+ }
148
+ if (_isBrowser) {
149
+ await widget.controller.refreshBrowserFrameRuntime();
150
+ return;
151
+ }
152
+ if (_isDesktop) {
153
+ await widget.controller.refreshDesktopFrameRuntime();
154
+ return;
155
+ }
156
+ if (_androidStarting) {
157
+ await widget.controller.refreshDevices();
158
+ if (_androidOnline &&
159
+ (widget.controller.androidScreenshotPath ?? '').isEmpty) {
160
+ await widget.controller.screenshotAndroidRuntime();
161
+ }
162
+ return;
163
+ }
164
+ await widget.controller.refreshAndroidFrameRuntime();
165
+ }
166
+
167
+ Future<void> _switchSurface(int delta) async {
168
+ final surfaces = _DeviceSurface.values;
169
+ final currentIndex = surfaces.indexOf(_surface);
170
+ final nextIndex = (currentIndex + delta) % surfaces.length;
171
+ setState(
172
+ () =>
173
+ _surface = surfaces[nextIndex < 0 ? surfaces.length - 1 : nextIndex],
174
+ );
175
+ await _ensurePreview();
176
+ }
177
+
178
+ Future<void> _openPrimary() async {
179
+ final controller = widget.controller;
180
+ if (_isBrowser) {
181
+ await controller.navigateBrowserRuntime(
182
+ url: _browserUrlController.text.trim(),
183
+ );
184
+ return;
185
+ }
186
+
187
+ if (_isDesktop) {
188
+ if (_desktopRequiresSelection) {
189
+ final selectedId = widget.controller.selectedDesktopDeviceId;
190
+ if ((selectedId ?? '').isEmpty && _onlineDesktopDevices.length == 1) {
191
+ await controller.selectDesktopDeviceRuntime(
192
+ _onlineDesktopDevices.first['deviceId']?.toString() ?? '',
193
+ );
194
+ } else {
195
+ await controller.openDesktopSelectionRuntime();
196
+ }
197
+ return;
198
+ }
199
+ if (!_desktopOnline) {
200
+ return;
201
+ }
202
+ final selectedId = widget.controller.selectedDesktopDeviceId;
203
+ if ((selectedId ?? '').isEmpty && _onlineDesktopDevices.length == 1) {
204
+ await controller.selectDesktopDeviceRuntime(
205
+ _onlineDesktopDevices.first['deviceId']?.toString() ?? '',
206
+ );
207
+ }
208
+ final raw = _desktopLaunchController.text.trim();
209
+ if (raw.isNotEmpty) {
210
+ await controller.launchDesktopAppRuntime(raw);
211
+ return;
212
+ }
213
+ await controller.observeDesktopRuntime();
214
+ return;
215
+ }
216
+
217
+ if (!_androidOnline) {
218
+ await controller.startAndroidRuntime();
219
+ await widget.controller.refreshDevices();
220
+ if (_androidOnline) {
221
+ await controller.screenshotAndroidRuntime();
222
+ }
223
+ return;
224
+ }
225
+
226
+ final raw = _androidLaunchController.text.trim();
227
+ if (raw.isEmpty) {
228
+ return;
229
+ }
230
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
231
+ await controller.openAndroidIntentRuntime(
232
+ action: 'android.intent.action.VIEW',
233
+ dataUri: raw,
234
+ );
235
+ return;
236
+ }
237
+ await controller.openAndroidAppRuntime(packageName: raw);
238
+ }
239
+
240
+ Future<void> _sleepPrimary() async {
241
+ final controller = widget.controller;
242
+ if (_isBrowser) {
243
+ if (controller.browserRuntime['launched'] != true) {
244
+ return;
245
+ }
246
+ await controller.closeBrowserRuntime();
247
+ return;
248
+ }
249
+ if (_isDesktop) {
250
+ final selectedId = widget.controller.selectedDesktopDeviceId;
251
+ if ((selectedId ?? '').isNotEmpty) {
252
+ await controller.pauseDesktopDeviceRuntime(selectedId!);
253
+ }
254
+ return;
255
+ }
256
+ if (!_androidOnline) {
257
+ return;
258
+ }
259
+ await controller.stopAndroidRuntime();
260
+ }
261
+
262
+ Future<void> _sendText() async {
263
+ final text = _textEntryController.text;
264
+ if (text.trim().isEmpty) {
265
+ return;
266
+ }
267
+ if (_isBrowser) {
268
+ await widget.controller.typeBrowserTextRuntime(text, pressEnter: true);
269
+ } else if (_isDesktop) {
270
+ await widget.controller.typeDesktopRuntime(text, pressEnter: true);
271
+ } else {
272
+ await widget.controller.typeAndroidRuntime(<String, dynamic>{
273
+ 'text': text,
274
+ 'pressEnter': true,
275
+ });
276
+ }
277
+ }
278
+
279
+ Future<void> _handleTap(Offset point) async {
280
+ if (_isBrowser) {
281
+ await widget.controller.clickBrowserPointRuntime(
282
+ x: point.dx.round(),
283
+ y: point.dy.round(),
284
+ );
285
+ return;
286
+ }
287
+ if (_isDesktop) {
288
+ if (_desktopRequiresSelection) {
289
+ return;
290
+ }
291
+ await widget.controller.clickDesktopRuntime(
292
+ x: point.dx.round(),
293
+ y: point.dy.round(),
294
+ );
295
+ return;
296
+ }
297
+ if (!_androidOnline) {
298
+ await widget.controller.startAndroidRuntime();
299
+ return;
300
+ }
301
+ await widget.controller.tapAndroidRuntime(<String, dynamic>{
302
+ 'x': point.dx.round(),
303
+ 'y': point.dy.round(),
304
+ });
305
+ }
306
+
307
+ Future<void> _handleSwipe(Offset start, Offset end) async {
308
+ if (_isBrowser) {
309
+ await widget.controller.scrollBrowserRuntime(
310
+ deltaY: (start.dy - end.dy).round(),
311
+ );
312
+ return;
313
+ }
314
+ if (_isDesktop) {
315
+ if (_desktopRequiresSelection) {
316
+ return;
317
+ }
318
+ await widget.controller.dragDesktopRuntime(
319
+ x1: start.dx.round(),
320
+ y1: start.dy.round(),
321
+ x2: end.dx.round(),
322
+ y2: end.dy.round(),
323
+ );
324
+ return;
325
+ }
326
+ if (!_androidOnline) {
327
+ return;
328
+ }
329
+ await widget.controller.swipeAndroidRuntime(<String, dynamic>{
330
+ 'x1': start.dx.round(),
331
+ 'y1': start.dy.round(),
332
+ 'x2': end.dx.round(),
333
+ 'y2': end.dy.round(),
334
+ 'durationMs': 280,
335
+ });
336
+ }
337
+
338
+ Future<void> _runQuickAction(String action) async {
339
+ final controller = widget.controller;
340
+ switch (action) {
341
+ case 'browser_refresh':
342
+ await controller.navigateBrowserRuntime(
343
+ url: controller.browserRuntime['pageInfo'] is Map<dynamic, dynamic>
344
+ ? ((controller.browserRuntime['pageInfo'] as Map)['url']
345
+ ?.toString() ??
346
+ _browserUrlController.text.trim())
347
+ : _browserUrlController.text.trim(),
348
+ );
349
+ break;
350
+ case 'browser_enter':
351
+ await controller.pressBrowserKeyRuntime('Enter');
352
+ break;
353
+ case 'desktop_enter':
354
+ await controller.pressDesktopKeyRuntime('Return');
355
+ break;
356
+ case 'desktop_escape':
357
+ await controller.pressDesktopKeyRuntime('Escape');
358
+ break;
359
+ case 'android_back':
360
+ await controller.pressAndroidKeyRuntime('back');
361
+ break;
362
+ case 'android_home':
363
+ await controller.pressAndroidKeyRuntime('home');
364
+ break;
365
+ case 'android_recent':
366
+ await controller.pressAndroidKeyRuntime('app_switch');
367
+ break;
368
+ case 'surface_refresh':
369
+ await _ensurePreview();
370
+ break;
371
+ }
372
+ }
373
+
374
+ @override
375
+ Widget build(BuildContext context) {
376
+ final controller = widget.controller;
377
+ final browserStatus = controller.browserRuntime;
378
+ final prefersExtension = controller.browserBackend == 'extension';
379
+ final extensionConnected = controller.browserExtensionConnected;
380
+ final usingExtension = prefersExtension && extensionConnected;
381
+ final browserFallbackLabel = controller.cloudBrowserBackend == 'vm'
382
+ ? 'cloud VM'
383
+ : 'local host';
384
+ final browserPageInfo = browserStatus['pageInfo'] is Map<dynamic, dynamic>
385
+ ? Map<String, dynamic>.from(browserStatus['pageInfo'] as Map)
386
+ : const <String, dynamic>{};
387
+ final selectedDesktopDevice = controller.desktopDevices
388
+ .where(
389
+ (device) => device['deviceId'] == controller.selectedDesktopDeviceId,
390
+ )
391
+ .cast<Map<String, dynamic>?>()
392
+ .firstWhere((device) => device != null, orElse: () => null);
393
+ final desktopDeviceOnline = selectedDesktopDevice?['online'] == true;
394
+ return ListView(
395
+ padding: _pagePadding(context),
396
+ children: <Widget>[
397
+ _PageTitle(
398
+ title: 'Remote Device',
399
+ subtitle:
400
+ 'Tap, swipe, and type directly on the live surface. Use the arrows below to switch between browser, phone, and desktop.',
401
+ trailing: OutlinedButton.icon(
402
+ onPressed:
403
+ controller.isRefreshingDevices ||
404
+ controller.isRunningDeviceAction
405
+ ? null
406
+ : _bootstrapSurface,
407
+ icon: Icon(Icons.sync),
408
+ label: Text('Sync Surface'),
409
+ ),
410
+ ),
411
+ if (controller.errorMessage case final message?)
412
+ Padding(
413
+ padding: const EdgeInsets.only(bottom: 16),
414
+ child: _InlineError(message: message),
415
+ ),
416
+ Center(
417
+ child: ConstrainedBox(
418
+ constraints: const BoxConstraints(maxWidth: 980),
419
+ child: Card(
420
+ color: _bgCard,
421
+ shape: RoundedRectangleBorder(
422
+ borderRadius: BorderRadius.circular(28),
423
+ side: BorderSide(color: _borderLight),
424
+ ),
425
+ child: Padding(
426
+ padding: const EdgeInsets.all(20),
427
+ child: Column(
428
+ children: <Widget>[
429
+ _DeviceSurfaceHeader(
430
+ surface: _surface,
431
+ browserStatus: browserStatus,
432
+ browserPageInfo: browserPageInfo,
433
+ androidRuntime: controller.androidRuntime,
434
+ androidOnline: _androidOnline,
435
+ desktopRuntime: controller.desktopRuntime,
436
+ desktopDevices: controller.desktopDevices,
437
+ selectedDesktopDeviceId:
438
+ controller.selectedDesktopDeviceId,
439
+ browserExtensionPreferred: prefersExtension,
440
+ browserExtensionActive: usingExtension,
441
+ browserFallbackLabel: browserFallbackLabel,
442
+ ),
443
+ if (_isDesktop) ...<Widget>[
444
+ const SizedBox(height: 14),
445
+ DropdownButtonFormField<String>(
446
+ initialValue: selectedDesktopDevice?['deviceId']
447
+ ?.toString(),
448
+ decoration: const InputDecoration(
449
+ labelText: 'Desktop device',
450
+ prefixIcon: Icon(Icons.computer_outlined),
451
+ ),
452
+ hint: const Text('Select a companion desktop'),
453
+ items: controller.desktopDevices.map((device) {
454
+ final deviceId = device['deviceId']?.toString() ?? '';
455
+ final label =
456
+ device['label']?.toString().trim().isNotEmpty ==
457
+ true
458
+ ? device['label'].toString()
459
+ : (device['hostname']?.toString() ?? deviceId);
460
+ final os =
461
+ device['platform']?.toString() ?? 'desktop';
462
+ final state = device['online'] == true
463
+ ? (device['paused'] == true ? 'paused' : 'online')
464
+ : 'offline';
465
+ return DropdownMenuItem<String>(
466
+ value: deviceId,
467
+ child: Text('$label · $os · $state'),
468
+ );
469
+ }).toList(),
470
+ onChanged: controller.isRunningDeviceAction
471
+ ? null
472
+ : (value) {
473
+ if (value == null || value.isEmpty) {
474
+ return;
475
+ }
476
+ unawaited(
477
+ controller.selectDesktopDeviceRuntime(value),
478
+ );
479
+ },
480
+ ),
481
+ const SizedBox(height: 10),
482
+ Wrap(
483
+ spacing: 10,
484
+ runSpacing: 10,
485
+ children: <Widget>[
486
+ _DotStatus(
487
+ label: desktopDeviceOnline
488
+ ? 'Companion live'
489
+ : controller.desktopCompanionConnected
490
+ ? 'Companion live'
491
+ : controller.desktopCompanionConnecting
492
+ ? 'Connecting'
493
+ : 'Companion idle',
494
+ color: desktopDeviceOnline
495
+ ? _success
496
+ : controller.desktopCompanionConnected
497
+ ? _success
498
+ : controller.desktopCompanionConnecting
499
+ ? _accent
500
+ : _warning,
501
+ ),
502
+ if (selectedDesktopDevice != null)
503
+ _DotStatus(
504
+ label: selectedDesktopDevice['paused'] == true
505
+ ? 'Paused'
506
+ : (selectedDesktopDevice['online'] == true
507
+ ? 'Ready'
508
+ : 'Offline'),
509
+ color: selectedDesktopDevice['paused'] == true
510
+ ? _warning
511
+ : (selectedDesktopDevice['online'] == true
512
+ ? _success
513
+ : _textMuted),
514
+ ),
515
+ ],
516
+ ),
517
+ ],
518
+ const SizedBox(height: 16),
519
+ _DeviceLaunchBar(
520
+ surface: _surface,
521
+ controller: _isBrowser
522
+ ? _browserUrlController
523
+ : (_isDesktop
524
+ ? _desktopLaunchController
525
+ : _androidLaunchController),
526
+ active: _isBrowser
527
+ ? browserStatus['launched'] == true
528
+ : (_isDesktop
529
+ ? _desktopOnline
530
+ : _androidOnline || _androidStarting),
531
+ starting: !_isBrowser && !_isDesktop && _androidStarting,
532
+ busy: controller.isRunningDeviceAction,
533
+ onSubmit: _openPrimary,
534
+ onSleep: _sleepPrimary,
535
+ ),
536
+ const SizedBox(height: 18),
537
+ _InteractiveSurfacePreview(
538
+ surface: _surface,
539
+ controller: controller,
540
+ screenshotPath: _activeScreenshotPath,
541
+ busy: controller.isRunningDeviceAction,
542
+ wakingUp: !_isBrowser && !_isDesktop && _androidStarting,
543
+ enabled: _isBrowser || _isDesktop || _androidOnline,
544
+ connectRequired: _desktopRequiresSelection,
545
+ onTapPoint: _handleTap,
546
+ onSwipe: _handleSwipe,
547
+ onWakeRequested: _openPrimary,
548
+ ),
549
+ if (!_isBrowser && !_isDesktop) ...<Widget>[
550
+ const SizedBox(height: 12),
551
+ _AndroidNavDock(
552
+ busy: controller.isRunningDeviceAction,
553
+ androidOnline: _androidOnline,
554
+ onAction: _runQuickAction,
555
+ ),
556
+ if (kIsWeb) ...<Widget>[
557
+ const SizedBox(height: 14),
558
+ AndroidApkDropZone(
559
+ enabled: _androidOnline,
560
+ busy: controller.isRunningDeviceAction,
561
+ onInstall: ({required filename, required bytes}) {
562
+ return controller.installAndroidApkRuntime(
563
+ filename: filename,
564
+ bytes: bytes,
565
+ );
566
+ },
567
+ ),
568
+ ],
569
+ ],
570
+ const SizedBox(height: 18),
571
+ _DeviceTypeDock(
572
+ controller: _textEntryController,
573
+ busy: controller.isRunningDeviceAction,
574
+ surface: _surface,
575
+ onSubmit: _sendText,
576
+ ),
577
+ if (_isBrowser || _isDesktop) ...<Widget>[
578
+ const SizedBox(height: 14),
579
+ _DeviceQuickActions(
580
+ surface: _surface,
581
+ androidOnline: _androidOnline,
582
+ busy: controller.isRunningDeviceAction,
583
+ onAction: _runQuickAction,
584
+ ),
585
+ ],
586
+ const SizedBox(height: 14),
587
+ _SurfaceSwitcher(
588
+ surface: _surface,
589
+ onPrevious: () => _switchSurface(-1),
590
+ onNext: () => _switchSurface(1),
591
+ ),
592
+ ],
593
+ ),
594
+ ),
595
+ ),
596
+ ),
597
+ ),
598
+ ],
599
+ );
600
+ }
601
+ }
602
+
603
+ enum _DeviceSurface { browser, android, desktop }
604
+
605
+ extension _DeviceSurfaceX on _DeviceSurface {
606
+ String get label => switch (this) {
607
+ _DeviceSurface.browser => 'Browser',
608
+ _DeviceSurface.android => 'Phone',
609
+ _DeviceSurface.desktop => 'Desktop',
610
+ };
611
+
612
+ String get helper => switch (this) {
613
+ _DeviceSurface.browser => 'Tap to click. Drag to scroll.',
614
+ _DeviceSurface.android => 'Tap to touch. Drag to swipe.',
615
+ _DeviceSurface.desktop =>
616
+ 'Tap to click. Drag to drag windows or selections.',
617
+ };
618
+
619
+ IconData get icon => switch (this) {
620
+ _DeviceSurface.browser => Icons.language_outlined,
621
+ _DeviceSurface.android => Icons.smartphone_outlined,
622
+ _DeviceSurface.desktop => Icons.computer_outlined,
623
+ };
624
+ }
625
+
626
+ class _DeviceSurfaceHeader extends StatelessWidget {
627
+ const _DeviceSurfaceHeader({
628
+ required this.surface,
629
+ required this.browserStatus,
630
+ required this.browserPageInfo,
631
+ required this.androidRuntime,
632
+ required this.androidOnline,
633
+ required this.desktopRuntime,
634
+ required this.desktopDevices,
635
+ required this.selectedDesktopDeviceId,
636
+ required this.browserExtensionPreferred,
637
+ required this.browserExtensionActive,
638
+ required this.browserFallbackLabel,
639
+ });
640
+
641
+ final _DeviceSurface surface;
642
+ final Map<String, dynamic> browserStatus;
643
+ final Map<String, dynamic> browserPageInfo;
644
+ final Map<String, dynamic> androidRuntime;
645
+ final bool androidOnline;
646
+ final Map<String, dynamic> desktopRuntime;
647
+ final List<Map<String, dynamic>> desktopDevices;
648
+ final String? selectedDesktopDeviceId;
649
+ final bool browserExtensionPreferred;
650
+ final bool browserExtensionActive;
651
+ final String browserFallbackLabel;
652
+
653
+ @override
654
+ Widget build(BuildContext context) {
655
+ final androidStarting = androidRuntime['starting'] == true;
656
+ final androidVersion = _androidRuntimeVersionLabel(androidRuntime);
657
+ final selectedDesktop = desktopDevices
658
+ .where((device) => device['deviceId'] == selectedDesktopDeviceId)
659
+ .cast<Map<String, dynamic>?>()
660
+ .firstWhere((device) => device != null, orElse: () => null);
661
+ final desktopOnlineCount = desktopDevices
662
+ .where((device) => device['online'] == true)
663
+ .length;
664
+ final title = switch (surface) {
665
+ _DeviceSurface.browser =>
666
+ (browserPageInfo['title']?.toString().trim().isNotEmpty ?? false)
667
+ ? browserPageInfo['title'].toString()
668
+ : 'Live Browser',
669
+ _DeviceSurface.android => 'Android Phone',
670
+ _DeviceSurface.desktop =>
671
+ selectedDesktop?['label']?.toString().trim().isNotEmpty == true
672
+ ? selectedDesktop!['label'].toString()
673
+ : 'Desktop Companion',
674
+ };
675
+ final subtitle = switch (surface) {
676
+ _DeviceSurface.browser =>
677
+ browserExtensionPreferred && !browserExtensionActive
678
+ ? 'No extension device is active. Using the $browserFallbackLabel browser fallback.'
679
+ : (browserPageInfo['url']?.toString() ?? 'Ready for navigation'),
680
+ _DeviceSurface.android =>
681
+ androidOnline
682
+ ? androidVersion == null
683
+ ? 'Tap and swipe directly on the preview.'
684
+ : '$androidVersion · Tap and swipe directly on the preview.'
685
+ : androidStarting
686
+ ? (androidRuntime['startupPhase']?.toString().trim().isNotEmpty ??
687
+ false)
688
+ ? androidRuntime['startupPhase'].toString()
689
+ : 'Starting the phone. This can take a little while.'
690
+ : (androidRuntime['lastLogLine']?.toString().trim().isNotEmpty ??
691
+ false)
692
+ ? androidRuntime['lastLogLine'].toString()
693
+ : androidVersion != null
694
+ ? '$androidVersion selected. Phone is offline.'
695
+ : 'Phone is offline. Open or start it from below.',
696
+ _DeviceSurface.desktop =>
697
+ selectedDesktop == null
698
+ ? desktopOnlineCount > 1
699
+ ? 'Multiple desktop companions are online. Pick the machine you want to control.'
700
+ : desktopOnlineCount == 1
701
+ ? 'One desktop companion is online. Open the surface to fetch the latest frame.'
702
+ : 'No desktop companion is online. Enable Companion Mode on a signed-in desktop app.'
703
+ : '${selectedDesktop['platform'] ?? 'desktop'} · ${selectedDesktop['hostname'] ?? 'unknown host'}',
704
+ };
705
+ final statusLabel = surface == _DeviceSurface.browser
706
+ ? browserExtensionPreferred && !browserExtensionActive
707
+ ? 'Fallback'
708
+ : browserExtensionActive
709
+ ? 'Extension'
710
+ : (browserStatus['launched'] == true ? 'Live' : 'Sleeping')
711
+ : surface == _DeviceSurface.desktop
712
+ ? selectedDesktop == null
713
+ ? (desktopOnlineCount > 0 ? 'Select Device' : 'Offline')
714
+ : (selectedDesktop['paused'] == true
715
+ ? 'Paused'
716
+ : (selectedDesktop['online'] == true ? 'Live' : 'Offline'))
717
+ : (androidOnline
718
+ ? 'Live'
719
+ : androidStarting
720
+ ? 'Starting'
721
+ : 'Offline');
722
+ final statusColor = surface == _DeviceSurface.browser
723
+ ? (browserStatus['launched'] == true ? _success : _warning)
724
+ : surface == _DeviceSurface.desktop
725
+ ? (selectedDesktop?['paused'] == true
726
+ ? _warning
727
+ : (selectedDesktop?['online'] == true ? _success : _warning))
728
+ : (androidOnline ? _success : (androidStarting ? _accent : _warning));
729
+
730
+ return Row(
731
+ children: <Widget>[
732
+ Container(
733
+ width: 48,
734
+ height: 48,
735
+ decoration: BoxDecoration(
736
+ color: _accentMuted,
737
+ borderRadius: BorderRadius.circular(14),
738
+ ),
739
+ child: Icon(surface.icon, color: _textPrimary),
740
+ ),
741
+ const SizedBox(width: 14),
742
+ Expanded(
743
+ child: Column(
744
+ crossAxisAlignment: CrossAxisAlignment.start,
745
+ children: <Widget>[
746
+ Text(
747
+ title,
748
+ maxLines: 1,
749
+ overflow: TextOverflow.ellipsis,
750
+ style: TextStyle(fontSize: 21, fontWeight: FontWeight.w800),
751
+ ),
752
+ const SizedBox(height: 4),
753
+ Text(
754
+ subtitle,
755
+ maxLines: 2,
756
+ overflow: TextOverflow.ellipsis,
757
+ style: TextStyle(color: _textSecondary, height: 1.4),
758
+ ),
759
+ ],
760
+ ),
761
+ ),
762
+ const SizedBox(width: 12),
763
+ _DotStatus(label: statusLabel, color: statusColor),
764
+ ],
765
+ );
766
+ }
767
+ }
768
+
769
+ class _DeviceLaunchBar extends StatelessWidget {
770
+ const _DeviceLaunchBar({
771
+ required this.surface,
772
+ required this.controller,
773
+ required this.active,
774
+ required this.starting,
775
+ required this.busy,
776
+ required this.onSubmit,
777
+ required this.onSleep,
778
+ });
779
+
780
+ final _DeviceSurface surface;
781
+ final TextEditingController controller;
782
+ final bool active;
783
+ final bool starting;
784
+ final bool busy;
785
+ final Future<void> Function() onSubmit;
786
+ final Future<void> Function() onSleep;
787
+
788
+ @override
789
+ Widget build(BuildContext context) {
790
+ final hint = switch (surface) {
791
+ _DeviceSurface.browser => _browserUrlPlaceholder,
792
+ _DeviceSurface.android => _packageOrUrlHint,
793
+ _DeviceSurface.desktop =>
794
+ 'Launch an app on the selected desktop (optional)',
795
+ };
796
+ final buttonLabel = switch (surface) {
797
+ _DeviceSurface.browser => 'Open',
798
+ _DeviceSurface.android => starting ? 'Starting...' : 'Launch',
799
+ _DeviceSurface.desktop => 'Refresh',
800
+ };
801
+ final sleepLabel = switch (surface) {
802
+ _DeviceSurface.browser => 'Sleep Browser',
803
+ _DeviceSurface.android => 'Sleep Phone',
804
+ _DeviceSurface.desktop => 'Pause Desktop',
805
+ };
806
+ final narrow = MediaQuery.sizeOf(context).width < 720;
807
+
808
+ final input = TextField(
809
+ controller: controller,
810
+ onSubmitted: (_) => onSubmit(),
811
+ decoration: InputDecoration(
812
+ hintText: hint,
813
+ prefixIcon: Icon(
814
+ surface == _DeviceSurface.browser
815
+ ? Icons.travel_explore
816
+ : surface == _DeviceSurface.desktop
817
+ ? Icons.apps_outlined
818
+ : Icons.open_in_new,
819
+ ),
820
+ ),
821
+ );
822
+
823
+ final button = FilledButton.icon(
824
+ onPressed: busy || starting ? null : onSubmit,
825
+ icon: Icon(
826
+ surface == _DeviceSurface.browser
827
+ ? Icons.arrow_forward
828
+ : surface == _DeviceSurface.desktop
829
+ ? Icons.desktop_windows_outlined
830
+ : Icons.play_arrow,
831
+ ),
832
+ label: Text(buttonLabel),
833
+ );
834
+ final sleepButton = OutlinedButton.icon(
835
+ onPressed: busy || !active ? null : onSleep,
836
+ icon: Icon(Icons.bedtime_outlined),
837
+ label: Text(sleepLabel),
838
+ );
839
+
840
+ if (narrow) {
841
+ return Column(
842
+ children: <Widget>[
843
+ input,
844
+ const SizedBox(height: 10),
845
+ Row(
846
+ children: <Widget>[
847
+ Expanded(child: button),
848
+ const SizedBox(width: 10),
849
+ Expanded(child: sleepButton),
850
+ ],
851
+ ),
852
+ ],
853
+ );
854
+ }
855
+
856
+ return Row(
857
+ children: <Widget>[
858
+ Expanded(child: input),
859
+ const SizedBox(width: 10),
860
+ sleepButton,
861
+ const SizedBox(width: 10),
862
+ button,
863
+ ],
864
+ );
865
+ }
866
+ }
867
+
868
+ class _DeviceTypeDock extends StatelessWidget {
869
+ const _DeviceTypeDock({
870
+ required this.controller,
871
+ required this.busy,
872
+ required this.surface,
873
+ required this.onSubmit,
874
+ });
875
+
876
+ final TextEditingController controller;
877
+ final bool busy;
878
+ final _DeviceSurface surface;
879
+ final Future<void> Function() onSubmit;
880
+
881
+ @override
882
+ Widget build(BuildContext context) {
883
+ final hint = switch (surface) {
884
+ _DeviceSurface.browser => 'Type into the currently focused field',
885
+ _DeviceSurface.android => 'Type into the current phone field',
886
+ _DeviceSurface.desktop => 'Type into the focused desktop field',
887
+ };
888
+ final narrow = MediaQuery.sizeOf(context).width < 720;
889
+
890
+ final input = TextField(
891
+ controller: controller,
892
+ onSubmitted: (_) => onSubmit(),
893
+ decoration: InputDecoration(
894
+ hintText: hint,
895
+ prefixIcon: Icon(Icons.keyboard_outlined),
896
+ ),
897
+ );
898
+
899
+ final button = FilledButton.icon(
900
+ onPressed: busy ? null : onSubmit,
901
+ icon: Icon(Icons.send_rounded),
902
+ label: Text('Send'),
903
+ );
904
+
905
+ if (narrow) {
906
+ return Column(
907
+ children: <Widget>[
908
+ input,
909
+ const SizedBox(height: 10),
910
+ SizedBox(width: double.infinity, child: button),
911
+ ],
912
+ );
913
+ }
914
+
915
+ return Row(
916
+ children: <Widget>[
917
+ Expanded(child: input),
918
+ const SizedBox(width: 10),
919
+ button,
920
+ ],
921
+ );
922
+ }
923
+ }
924
+
925
+ class _DeviceQuickActions extends StatelessWidget {
926
+ const _DeviceQuickActions({
927
+ required this.surface,
928
+ required this.androidOnline,
929
+ required this.busy,
930
+ required this.onAction,
931
+ });
932
+
933
+ final _DeviceSurface surface;
934
+ final bool androidOnline;
935
+ final bool busy;
936
+ final Future<void> Function(String action) onAction;
937
+
938
+ @override
939
+ Widget build(BuildContext context) {
940
+ final actions = switch (surface) {
941
+ _DeviceSurface.browser => const <MapEntry<String, IconData>>[
942
+ MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
943
+ MapEntry<String, IconData>('browser_refresh', Icons.replay_rounded),
944
+ MapEntry<String, IconData>(
945
+ 'browser_enter',
946
+ Icons.keyboard_return_rounded,
947
+ ),
948
+ ],
949
+ _DeviceSurface.desktop => const <MapEntry<String, IconData>>[
950
+ MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
951
+ MapEntry<String, IconData>(
952
+ 'desktop_enter',
953
+ Icons.keyboard_return_rounded,
954
+ ),
955
+ MapEntry<String, IconData>('desktop_escape', Icons.close_fullscreen),
956
+ ],
957
+ _DeviceSurface.android => const <MapEntry<String, IconData>>[
958
+ MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
959
+ ],
960
+ };
961
+
962
+ return Wrap(
963
+ spacing: 10,
964
+ runSpacing: 10,
965
+ children: actions.map((entry) {
966
+ final disabled =
967
+ busy ||
968
+ (surface != _DeviceSurface.browser &&
969
+ entry.key.startsWith('browser_')) ||
970
+ (surface != _DeviceSurface.desktop &&
971
+ entry.key.startsWith('desktop_')) ||
972
+ (!androidOnline && entry.key.startsWith('android_'));
973
+ return InkWell(
974
+ onTap: disabled ? null : () => onAction(entry.key),
975
+ borderRadius: BorderRadius.circular(14),
976
+ child: Ink(
977
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
978
+ decoration: BoxDecoration(
979
+ color: disabled ? _bgSecondary : _bgTertiary,
980
+ borderRadius: BorderRadius.circular(14),
981
+ border: Border.all(color: _borderLight),
982
+ ),
983
+ child: Icon(
984
+ entry.value,
985
+ color: disabled ? _textMuted : _textPrimary,
986
+ ),
987
+ ),
988
+ );
989
+ }).toList(),
990
+ );
991
+ }
992
+ }
993
+
994
+ class _AndroidNavDock extends StatelessWidget {
995
+ const _AndroidNavDock({
996
+ required this.busy,
997
+ required this.androidOnline,
998
+ required this.onAction,
999
+ });
1000
+
1001
+ final bool busy;
1002
+ final bool androidOnline;
1003
+ final Future<void> Function(String action) onAction;
1004
+
1005
+ @override
1006
+ Widget build(BuildContext context) {
1007
+ const actions = <MapEntry<String, IconData>>[
1008
+ MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
1009
+ MapEntry<String, IconData>('android_back', Icons.arrow_back_rounded),
1010
+ MapEntry<String, IconData>('android_home', Icons.home_rounded),
1011
+ MapEntry<String, IconData>('android_recent', Icons.crop_square_rounded),
1012
+ ];
1013
+
1014
+ return Container(
1015
+ width: double.infinity,
1016
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
1017
+ decoration: BoxDecoration(
1018
+ color: _bgSecondary,
1019
+ borderRadius: BorderRadius.circular(18),
1020
+ border: Border.all(color: _borderLight),
1021
+ ),
1022
+ child: Row(
1023
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
1024
+ children: actions.map((entry) {
1025
+ final disabled =
1026
+ busy || (!androidOnline && entry.key.startsWith('android_'));
1027
+ return IconButton.filledTonal(
1028
+ onPressed: disabled ? null : () => onAction(entry.key),
1029
+ icon: Icon(entry.value),
1030
+ );
1031
+ }).toList(),
1032
+ ),
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ class _SurfaceSwitcher extends StatelessWidget {
1038
+ const _SurfaceSwitcher({
1039
+ required this.surface,
1040
+ required this.onPrevious,
1041
+ required this.onNext,
1042
+ });
1043
+
1044
+ final _DeviceSurface surface;
1045
+ final VoidCallback onPrevious;
1046
+ final VoidCallback onNext;
1047
+
1048
+ @override
1049
+ Widget build(BuildContext context) {
1050
+ return Row(
1051
+ mainAxisAlignment: MainAxisAlignment.center,
1052
+ children: <Widget>[
1053
+ IconButton.filledTonal(
1054
+ onPressed: onPrevious,
1055
+ icon: Icon(Icons.arrow_back_ios_new_rounded),
1056
+ ),
1057
+ const SizedBox(width: 14),
1058
+ Column(
1059
+ mainAxisSize: MainAxisSize.min,
1060
+ children: <Widget>[
1061
+ Text(
1062
+ surface.label,
1063
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
1064
+ ),
1065
+ const SizedBox(height: 4),
1066
+ Text(surface.helper, style: TextStyle(color: _textSecondary)),
1067
+ ],
1068
+ ),
1069
+ const SizedBox(width: 14),
1070
+ IconButton.filledTonal(
1071
+ onPressed: onNext,
1072
+ icon: Icon(Icons.arrow_forward_ios_rounded),
1073
+ ),
1074
+ ],
1075
+ );
1076
+ }
1077
+ }
1078
+
1079
+ class _InteractiveSurfacePreview extends StatefulWidget {
1080
+ const _InteractiveSurfacePreview({
1081
+ required this.surface,
1082
+ required this.controller,
1083
+ required this.screenshotPath,
1084
+ required this.busy,
1085
+ required this.wakingUp,
1086
+ required this.enabled,
1087
+ required this.connectRequired,
1088
+ required this.onTapPoint,
1089
+ required this.onSwipe,
1090
+ required this.onWakeRequested,
1091
+ });
1092
+
1093
+ final _DeviceSurface surface;
1094
+ final NeoAgentController controller;
1095
+ final String? screenshotPath;
1096
+ final bool busy;
1097
+ final bool wakingUp;
1098
+ final bool enabled;
1099
+ final bool connectRequired;
1100
+ final Future<void> Function(Offset point) onTapPoint;
1101
+ final Future<void> Function(Offset start, Offset end) onSwipe;
1102
+ final Future<void> Function() onWakeRequested;
1103
+
1104
+ @override
1105
+ State<_InteractiveSurfacePreview> createState() =>
1106
+ _InteractiveSurfacePreviewState();
1107
+ }
1108
+
1109
+ class _InteractiveSurfacePreviewState
1110
+ extends State<_InteractiveSurfacePreview> {
1111
+ ImageStream? _imageStream;
1112
+ ImageStreamListener? _imageListener;
1113
+ Size? _pixelSize;
1114
+ Uint8List? _imageBytes;
1115
+ Object? _imageError;
1116
+ Offset? _dragStart;
1117
+ Offset? _dragEnd;
1118
+
1119
+ @override
1120
+ void initState() {
1121
+ super.initState();
1122
+ unawaited(_loadImage());
1123
+ }
1124
+
1125
+ @override
1126
+ void didUpdateWidget(covariant _InteractiveSurfacePreview oldWidget) {
1127
+ super.didUpdateWidget(oldWidget);
1128
+ if (oldWidget.screenshotPath != widget.screenshotPath) {
1129
+ unawaited(_loadImage());
1130
+ }
1131
+ }
1132
+
1133
+ @override
1134
+ void dispose() {
1135
+ _detachImageListener();
1136
+ super.dispose();
1137
+ }
1138
+
1139
+ void _detachImageListener() {
1140
+ if (_imageStream != null && _imageListener != null) {
1141
+ _imageStream!.removeListener(_imageListener!);
1142
+ }
1143
+ _imageStream = null;
1144
+ _imageListener = null;
1145
+ }
1146
+
1147
+ Future<void> _loadImage() async {
1148
+ _detachImageListener();
1149
+ final path = widget.screenshotPath;
1150
+ if (path == null || path.isEmpty) {
1151
+ if (!mounted) {
1152
+ return;
1153
+ }
1154
+ setState(() {
1155
+ _pixelSize = null;
1156
+ _imageBytes = null;
1157
+ _imageError = null;
1158
+ });
1159
+ return;
1160
+ }
1161
+ try {
1162
+ final bytes = await widget.controller.fetchRuntimeAssetBytes(path);
1163
+ if (!mounted || widget.screenshotPath != path) {
1164
+ return;
1165
+ }
1166
+ setState(() {
1167
+ _imageBytes = bytes;
1168
+ _imageError = null;
1169
+ _pixelSize = null;
1170
+ });
1171
+ final provider = MemoryImage(bytes);
1172
+ final stream = provider.resolve(const ImageConfiguration());
1173
+ final listener = ImageStreamListener((image, _) {
1174
+ if (!mounted || widget.screenshotPath != path) {
1175
+ return;
1176
+ }
1177
+ setState(() {
1178
+ _pixelSize = Size(
1179
+ image.image.width.toDouble(),
1180
+ image.image.height.toDouble(),
1181
+ );
1182
+ });
1183
+ });
1184
+ _imageStream = stream;
1185
+ _imageListener = listener;
1186
+ stream.addListener(listener);
1187
+ } catch (error) {
1188
+ if (!mounted || widget.screenshotPath != path) {
1189
+ return;
1190
+ }
1191
+ setState(() {
1192
+ _imageBytes = null;
1193
+ _pixelSize = null;
1194
+ _imageError = error;
1195
+ });
1196
+ }
1197
+ }
1198
+
1199
+ Offset? _mapToPixels(Offset localPosition, Size boxSize) {
1200
+ final pixelSize = _pixelSize;
1201
+ if (pixelSize == null) {
1202
+ return null;
1203
+ }
1204
+ final boxAspect = boxSize.width / boxSize.height;
1205
+ final imageAspect = pixelSize.width / pixelSize.height;
1206
+ late final double renderWidth;
1207
+ late final double renderHeight;
1208
+ late final double offsetX;
1209
+ late final double offsetY;
1210
+
1211
+ if (boxAspect > imageAspect) {
1212
+ renderHeight = boxSize.height;
1213
+ renderWidth = renderHeight * imageAspect;
1214
+ offsetX = (boxSize.width - renderWidth) / 2;
1215
+ offsetY = 0;
1216
+ } else {
1217
+ renderWidth = boxSize.width;
1218
+ renderHeight = renderWidth / imageAspect;
1219
+ offsetX = 0;
1220
+ offsetY = (boxSize.height - renderHeight) / 2;
1221
+ }
1222
+
1223
+ if (localPosition.dx < offsetX ||
1224
+ localPosition.dx > offsetX + renderWidth ||
1225
+ localPosition.dy < offsetY ||
1226
+ localPosition.dy > offsetY + renderHeight) {
1227
+ return null;
1228
+ }
1229
+
1230
+ return Offset(
1231
+ ((localPosition.dx - offsetX) / renderWidth) * pixelSize.width,
1232
+ ((localPosition.dy - offsetY) / renderHeight) * pixelSize.height,
1233
+ );
1234
+ }
1235
+
1236
+ @override
1237
+ Widget build(BuildContext context) {
1238
+ final path = widget.screenshotPath;
1239
+ final hasImage = path != null && path.isNotEmpty;
1240
+ final aspectRatio = switch (widget.surface) {
1241
+ _DeviceSurface.browser => 16 / 10,
1242
+ _DeviceSurface.android => 10 / 16,
1243
+ _DeviceSurface.desktop => 16 / 10,
1244
+ };
1245
+
1246
+ return Container(
1247
+ decoration: BoxDecoration(
1248
+ gradient: LinearGradient(
1249
+ begin: Alignment.topLeft,
1250
+ end: Alignment.bottomRight,
1251
+ colors: <Color>[_bgTertiary, _bgSecondary],
1252
+ ),
1253
+ borderRadius: BorderRadius.circular(24),
1254
+ border: Border.all(color: _borderLight),
1255
+ ),
1256
+ padding: const EdgeInsets.all(14),
1257
+ child: Column(
1258
+ children: <Widget>[
1259
+ ConstrainedBox(
1260
+ constraints: BoxConstraints(
1261
+ maxHeight: widget.surface == _DeviceSurface.android ? 640 : 560,
1262
+ ),
1263
+ child: AspectRatio(
1264
+ aspectRatio: aspectRatio,
1265
+ child: ClipRRect(
1266
+ borderRadius: BorderRadius.circular(20),
1267
+ child: LayoutBuilder(
1268
+ builder: (context, constraints) {
1269
+ final boxSize = Size(
1270
+ constraints.maxWidth,
1271
+ constraints.maxHeight,
1272
+ );
1273
+ if (!hasImage) {
1274
+ return _EmptySurfaceState(
1275
+ surface: widget.surface,
1276
+ enabled: widget.enabled,
1277
+ busy: widget.busy,
1278
+ isLoadingPreview: widget.wakingUp,
1279
+ connectRequired: widget.connectRequired,
1280
+ errorMessage: _imageError?.toString(),
1281
+ onPressed: widget.onWakeRequested,
1282
+ );
1283
+ }
1284
+ final imageBytes = _imageBytes;
1285
+ if (imageBytes == null) {
1286
+ return _EmptySurfaceState(
1287
+ surface: widget.surface,
1288
+ enabled: widget.enabled,
1289
+ busy: widget.busy,
1290
+ isLoadingPreview: _imageError == null,
1291
+ connectRequired: widget.connectRequired,
1292
+ errorMessage: _imageError?.toString(),
1293
+ onPressed: widget.onWakeRequested,
1294
+ );
1295
+ }
1296
+ return GestureDetector(
1297
+ onTapUp: widget.busy
1298
+ ? null
1299
+ : (details) async {
1300
+ final point = _mapToPixels(
1301
+ details.localPosition,
1302
+ boxSize,
1303
+ );
1304
+ if (point != null) {
1305
+ await widget.onTapPoint(point);
1306
+ }
1307
+ },
1308
+ onPanStart: widget.busy
1309
+ ? null
1310
+ : (details) {
1311
+ _dragStart = details.localPosition;
1312
+ _dragEnd = details.localPosition;
1313
+ },
1314
+ onPanUpdate: widget.busy
1315
+ ? null
1316
+ : (details) {
1317
+ _dragEnd = details.localPosition;
1318
+ },
1319
+ onPanEnd: widget.busy
1320
+ ? null
1321
+ : (_) async {
1322
+ final start = _dragStart;
1323
+ final end = _dragEnd;
1324
+ _dragStart = null;
1325
+ _dragEnd = null;
1326
+ if (start == null || end == null) {
1327
+ return;
1328
+ }
1329
+ if ((start - end).distance < 12) {
1330
+ return;
1331
+ }
1332
+ final mappedStart = _mapToPixels(start, boxSize);
1333
+ final mappedEnd = _mapToPixels(end, boxSize);
1334
+ if (mappedStart != null && mappedEnd != null) {
1335
+ await widget.onSwipe(mappedStart, mappedEnd);
1336
+ }
1337
+ },
1338
+ child: Stack(
1339
+ fit: StackFit.expand,
1340
+ children: <Widget>[
1341
+ Container(color: _bgSecondary),
1342
+ Image.memory(
1343
+ imageBytes,
1344
+ fit: BoxFit.contain,
1345
+ gaplessPlayback: true,
1346
+ ),
1347
+ Positioned(
1348
+ left: 12,
1349
+ right: 12,
1350
+ bottom: 12,
1351
+ child: Container(
1352
+ padding: const EdgeInsets.symmetric(
1353
+ horizontal: 12,
1354
+ vertical: 10,
1355
+ ),
1356
+ decoration: BoxDecoration(
1357
+ color: const Color(0xB205080D),
1358
+ borderRadius: BorderRadius.circular(14),
1359
+ border: Border.all(color: _borderLight),
1360
+ ),
1361
+ child: Text(
1362
+ widget.surface.helper,
1363
+ textAlign: TextAlign.center,
1364
+ style: TextStyle(color: _textPrimary),
1365
+ ),
1366
+ ),
1367
+ ),
1368
+ if (widget.busy)
1369
+ const Center(child: CircularProgressIndicator()),
1370
+ ],
1371
+ ),
1372
+ );
1373
+ },
1374
+ ),
1375
+ ),
1376
+ ),
1377
+ ),
1378
+ ],
1379
+ ),
1380
+ );
1381
+ }
1382
+ }
1383
+
1384
+ class _EmptySurfaceState extends StatelessWidget {
1385
+ const _EmptySurfaceState({
1386
+ required this.surface,
1387
+ required this.enabled,
1388
+ required this.busy,
1389
+ required this.isLoadingPreview,
1390
+ required this.connectRequired,
1391
+ this.errorMessage,
1392
+ required this.onPressed,
1393
+ });
1394
+
1395
+ final _DeviceSurface surface;
1396
+ final bool enabled;
1397
+ final bool busy;
1398
+ final bool isLoadingPreview;
1399
+ final bool connectRequired;
1400
+ final String? errorMessage;
1401
+ final Future<void> Function() onPressed;
1402
+
1403
+ @override
1404
+ Widget build(BuildContext context) {
1405
+ final label = switch (surface) {
1406
+ _DeviceSurface.browser =>
1407
+ connectRequired
1408
+ ? 'Open Settings'
1409
+ : (busy ? 'Opening Browser...' : 'Wake Browser'),
1410
+ _DeviceSurface.android =>
1411
+ busy
1412
+ ? 'Starting Phone...'
1413
+ : (enabled ? 'Refresh Phone' : 'Start Phone'),
1414
+ _DeviceSurface.desktop =>
1415
+ connectRequired
1416
+ ? 'Select Desktop'
1417
+ : (busy ? 'Refreshing Desktop...' : 'Refresh Desktop'),
1418
+ };
1419
+ final message = switch ((surface, busy, isLoadingPreview)) {
1420
+ (_DeviceSurface.browser, true, _) =>
1421
+ 'Opening the browser and downloading the first preview...',
1422
+ (_DeviceSurface.browser, false, true) =>
1423
+ 'Downloading the latest browser preview...',
1424
+ (_DeviceSurface.desktop, true, _) =>
1425
+ 'Refreshing the selected desktop companion...',
1426
+ (_DeviceSurface.desktop, false, true) =>
1427
+ 'Downloading the latest desktop preview...',
1428
+ (_DeviceSurface.android, true, _) =>
1429
+ 'Waking the phone and downloading the first preview. This can take a little while.',
1430
+ (_DeviceSurface.android, false, true) =>
1431
+ 'Downloading the latest phone preview...',
1432
+ _ =>
1433
+ surface == _DeviceSurface.browser
1434
+ ? connectRequired
1435
+ ? 'Chrome extension is not connected. Use Settings to download, load, and pair the extension on the remote machine.'
1436
+ : 'Browser is sleeping. Press Open to start it.'
1437
+ : surface == _DeviceSurface.desktop
1438
+ ? connectRequired
1439
+ ? 'Multiple desktop companions are online. Select a machine before sending clicks or keystrokes.'
1440
+ : (errorMessage != null && errorMessage!.trim().isNotEmpty)
1441
+ ? errorMessage!
1442
+ : 'No desktop frame is loaded yet. Refresh the selected machine to capture a frame.'
1443
+ : (errorMessage != null && errorMessage!.trim().isNotEmpty)
1444
+ ? errorMessage!
1445
+ : 'Phone is offline. Press Start Phone to boot it.',
1446
+ };
1447
+ return Container(
1448
+ color: _bgSecondary,
1449
+ alignment: Alignment.center,
1450
+ child: Column(
1451
+ mainAxisSize: MainAxisSize.min,
1452
+ children: <Widget>[
1453
+ if (busy || isLoadingPreview) ...<Widget>[
1454
+ const SizedBox(
1455
+ width: 38,
1456
+ height: 38,
1457
+ child: CircularProgressIndicator(strokeWidth: 3),
1458
+ ),
1459
+ const SizedBox(height: 14),
1460
+ ] else
1461
+ Icon(surface.icon, size: 46, color: _textSecondary),
1462
+ if (!(busy || isLoadingPreview)) const SizedBox(height: 12),
1463
+ Text(
1464
+ message,
1465
+ style: TextStyle(color: _textSecondary),
1466
+ textAlign: TextAlign.center,
1467
+ ),
1468
+ if (errorMessage != null &&
1469
+ surface == _DeviceSurface.browser &&
1470
+ errorMessage!.trim().isNotEmpty) ...<Widget>[
1471
+ const SizedBox(height: 10),
1472
+ ConstrainedBox(
1473
+ constraints: const BoxConstraints(maxWidth: 320),
1474
+ child: Text(
1475
+ errorMessage!,
1476
+ textAlign: TextAlign.center,
1477
+ style: TextStyle(color: _textMuted, fontSize: 12),
1478
+ ),
1479
+ ),
1480
+ ],
1481
+ const SizedBox(height: 16),
1482
+ FilledButton.icon(
1483
+ onPressed: busy ? null : onPressed,
1484
+ icon: Icon(Icons.play_arrow_rounded),
1485
+ label: Text(label),
1486
+ ),
1487
+ ],
1488
+ ),
1489
+ );
1490
+ }
1491
+ }
1492
+
1493
+ // ignore: unused_element
1494
+ class _RuntimeControlCard extends StatelessWidget {
1495
+ const _RuntimeControlCard({
1496
+ required this.title,
1497
+ required this.subtitle,
1498
+ required this.status,
1499
+ required this.child,
1500
+ });
1501
+
1502
+ final String title;
1503
+ final String subtitle;
1504
+ final Widget status;
1505
+ final Widget child;
1506
+
1507
+ @override
1508
+ Widget build(BuildContext context) {
1509
+ return Card(
1510
+ child: Padding(
1511
+ padding: const EdgeInsets.all(20),
1512
+ child: Column(
1513
+ crossAxisAlignment: CrossAxisAlignment.start,
1514
+ children: <Widget>[
1515
+ Row(
1516
+ crossAxisAlignment: CrossAxisAlignment.start,
1517
+ children: <Widget>[
1518
+ Expanded(
1519
+ child: Column(
1520
+ crossAxisAlignment: CrossAxisAlignment.start,
1521
+ children: <Widget>[
1522
+ Text(
1523
+ title,
1524
+ style: TextStyle(
1525
+ fontSize: 20,
1526
+ fontWeight: FontWeight.w800,
1527
+ ),
1528
+ ),
1529
+ const SizedBox(height: 6),
1530
+ Text(
1531
+ subtitle,
1532
+ style: TextStyle(color: _textSecondary, height: 1.5),
1533
+ ),
1534
+ ],
1535
+ ),
1536
+ ),
1537
+ const SizedBox(width: 12),
1538
+ status,
1539
+ ],
1540
+ ),
1541
+ const SizedBox(height: 18),
1542
+ child,
1543
+ ],
1544
+ ),
1545
+ ),
1546
+ );
1547
+ }
1548
+ }
1549
+
1550
+ // ignore: unused_element
1551
+ class _BrowserControls extends StatelessWidget {
1552
+ const _BrowserControls({
1553
+ required this.controller,
1554
+ required this.browserStatus,
1555
+ required this.browserPageInfo,
1556
+ required this.urlController,
1557
+ required this.waitForController,
1558
+ required this.clickSelectorController,
1559
+ required this.clickTextController,
1560
+ required this.fillSelectorController,
1561
+ required this.fillValueController,
1562
+ });
1563
+
1564
+ final NeoAgentController controller;
1565
+ final Map<String, dynamic> browserStatus;
1566
+ final Map<String, dynamic> browserPageInfo;
1567
+ final TextEditingController urlController;
1568
+ final TextEditingController waitForController;
1569
+ final TextEditingController clickSelectorController;
1570
+ final TextEditingController clickTextController;
1571
+ final TextEditingController fillSelectorController;
1572
+ final TextEditingController fillValueController;
1573
+
1574
+ @override
1575
+ Widget build(BuildContext context) {
1576
+ final launched = browserStatus['launched'] == true;
1577
+ return Column(
1578
+ crossAxisAlignment: CrossAxisAlignment.start,
1579
+ children: <Widget>[
1580
+ Wrap(
1581
+ spacing: 10,
1582
+ runSpacing: 10,
1583
+ children: <Widget>[
1584
+ _MetaPill(
1585
+ label: launched ? 'Launched' : 'Idle',
1586
+ icon: Icons.language_outlined,
1587
+ ),
1588
+ _MetaPill(
1589
+ label: 'Pages ${browserStatus['pages'] ?? 0}',
1590
+ icon: Icons.filter_none_outlined,
1591
+ ),
1592
+ _MetaPill(
1593
+ label: browserStatus['headless'] == false
1594
+ ? 'Visible window'
1595
+ : 'Headless',
1596
+ icon: Icons.visibility_outlined,
1597
+ ),
1598
+ ],
1599
+ ),
1600
+ if ((browserPageInfo['url']?.toString().isNotEmpty ?? false) ||
1601
+ (browserPageInfo['title']?.toString().isNotEmpty ??
1602
+ false)) ...<Widget>[
1603
+ const SizedBox(height: 14),
1604
+ SelectableText(
1605
+ '${browserPageInfo['title'] ?? 'Untitled'}\n${browserPageInfo['url'] ?? ''}',
1606
+ style: TextStyle(color: _textSecondary, height: 1.5),
1607
+ ),
1608
+ ],
1609
+ const SizedBox(height: 18),
1610
+ _DeviceFieldRow(
1611
+ children: <Widget>[
1612
+ _DeviceField(
1613
+ label: 'URL',
1614
+ child: TextField(controller: urlController),
1615
+ ),
1616
+ _DeviceField(
1617
+ label: 'Wait For Selector',
1618
+ child: TextField(controller: waitForController),
1619
+ ),
1620
+ ],
1621
+ ),
1622
+ const SizedBox(height: 10),
1623
+ Wrap(
1624
+ spacing: 10,
1625
+ runSpacing: 10,
1626
+ children: <Widget>[
1627
+ FilledButton.icon(
1628
+ onPressed: controller.isRunningDeviceAction
1629
+ ? null
1630
+ : controller.launchBrowserRuntime,
1631
+ icon: Icon(Icons.rocket_launch_outlined),
1632
+ label: Text('Launch'),
1633
+ ),
1634
+ FilledButton.icon(
1635
+ onPressed: controller.isRunningDeviceAction
1636
+ ? null
1637
+ : () => controller.navigateBrowserRuntime(
1638
+ url: urlController.text.trim(),
1639
+ waitFor: waitForController.text.trim(),
1640
+ ),
1641
+ icon: Icon(Icons.open_in_browser_outlined),
1642
+ label: Text('Navigate'),
1643
+ ),
1644
+ OutlinedButton.icon(
1645
+ onPressed: controller.isRunningDeviceAction
1646
+ ? null
1647
+ : controller.screenshotBrowserRuntime,
1648
+ icon: Icon(Icons.photo_camera_back_outlined),
1649
+ label: Text('Screenshot'),
1650
+ ),
1651
+ OutlinedButton.icon(
1652
+ onPressed: controller.isRunningDeviceAction
1653
+ ? null
1654
+ : controller.closeBrowserRuntime,
1655
+ icon: Icon(Icons.close),
1656
+ label: Text('Close'),
1657
+ ),
1658
+ ],
1659
+ ),
1660
+ const SizedBox(height: 18),
1661
+ _DeviceFieldRow(
1662
+ children: <Widget>[
1663
+ _DeviceField(
1664
+ label: 'Click Selector',
1665
+ child: TextField(controller: clickSelectorController),
1666
+ ),
1667
+ _DeviceField(
1668
+ label: 'Click Text',
1669
+ child: TextField(controller: clickTextController),
1670
+ ),
1671
+ ],
1672
+ ),
1673
+ const SizedBox(height: 10),
1674
+ Wrap(
1675
+ spacing: 10,
1676
+ runSpacing: 10,
1677
+ children: <Widget>[
1678
+ OutlinedButton(
1679
+ onPressed: controller.isRunningDeviceAction
1680
+ ? null
1681
+ : () => controller.clickBrowserRuntime(
1682
+ selector: clickSelectorController.text.trim(),
1683
+ ),
1684
+ child: Text('Click Selector'),
1685
+ ),
1686
+ OutlinedButton(
1687
+ onPressed: controller.isRunningDeviceAction
1688
+ ? null
1689
+ : () => controller.clickBrowserRuntime(
1690
+ text: clickTextController.text.trim(),
1691
+ ),
1692
+ child: Text('Click Text'),
1693
+ ),
1694
+ ],
1695
+ ),
1696
+ const SizedBox(height: 18),
1697
+ _DeviceFieldRow(
1698
+ children: <Widget>[
1699
+ _DeviceField(
1700
+ label: 'Type Selector',
1701
+ child: TextField(controller: fillSelectorController),
1702
+ ),
1703
+ _DeviceField(
1704
+ label: 'Value',
1705
+ child: TextField(controller: fillValueController),
1706
+ ),
1707
+ ],
1708
+ ),
1709
+ const SizedBox(height: 10),
1710
+ OutlinedButton.icon(
1711
+ onPressed: controller.isRunningDeviceAction
1712
+ ? null
1713
+ : () => controller.fillBrowserRuntime(
1714
+ selector: fillSelectorController.text.trim(),
1715
+ value: fillValueController.text,
1716
+ ),
1717
+ icon: Icon(Icons.keyboard_outlined),
1718
+ label: Text('Type Into Field'),
1719
+ ),
1720
+ const SizedBox(height: 18),
1721
+ _RuntimePreview(
1722
+ title: 'Latest Browser Screenshot',
1723
+ screenshotPath: controller.browserScreenshotPath,
1724
+ controller: controller,
1725
+ ),
1726
+ if (controller.browserLastResult?.trim().isNotEmpty ??
1727
+ false) ...<Widget>[
1728
+ const SizedBox(height: 14),
1729
+ _ResultBlock(
1730
+ label: 'Last browser result',
1731
+ value: controller.browserLastResult!,
1732
+ ),
1733
+ ],
1734
+ ],
1735
+ );
1736
+ }
1737
+ }
1738
+
1739
+ // ignore: unused_element
1740
+ class _AndroidControls extends StatelessWidget {
1741
+ const _AndroidControls({
1742
+ required this.controller,
1743
+ required this.androidStatus,
1744
+ required this.androidDevices,
1745
+ required this.packageController,
1746
+ required this.activityController,
1747
+ required this.intentActionController,
1748
+ required this.intentDataController,
1749
+ required this.tapTextController,
1750
+ required this.tapDescriptionController,
1751
+ required this.tapResourceIdController,
1752
+ required this.tapXController,
1753
+ required this.tapYController,
1754
+ required this.typeTextController,
1755
+ required this.typeFieldTextController,
1756
+ required this.typeFieldDescriptionController,
1757
+ required this.waitTextController,
1758
+ required this.keyController,
1759
+ required this.swipeX1Controller,
1760
+ required this.swipeY1Controller,
1761
+ required this.swipeX2Controller,
1762
+ required this.swipeY2Controller,
1763
+ required this.toInt,
1764
+ });
1765
+
1766
+ final NeoAgentController controller;
1767
+ final Map<String, dynamic> androidStatus;
1768
+ final List<Map<String, dynamic>> androidDevices;
1769
+ final TextEditingController packageController;
1770
+ final TextEditingController activityController;
1771
+ final TextEditingController intentActionController;
1772
+ final TextEditingController intentDataController;
1773
+ final TextEditingController tapTextController;
1774
+ final TextEditingController tapDescriptionController;
1775
+ final TextEditingController tapResourceIdController;
1776
+ final TextEditingController tapXController;
1777
+ final TextEditingController tapYController;
1778
+ final TextEditingController typeTextController;
1779
+ final TextEditingController typeFieldTextController;
1780
+ final TextEditingController typeFieldDescriptionController;
1781
+ final TextEditingController waitTextController;
1782
+ final TextEditingController keyController;
1783
+ final TextEditingController swipeX1Controller;
1784
+ final TextEditingController swipeY1Controller;
1785
+ final TextEditingController swipeX2Controller;
1786
+ final TextEditingController swipeY2Controller;
1787
+ final int? Function(String text) toInt;
1788
+
1789
+ @override
1790
+ Widget build(BuildContext context) {
1791
+ return Column(
1792
+ crossAxisAlignment: CrossAxisAlignment.start,
1793
+ children: <Widget>[
1794
+ Wrap(
1795
+ spacing: 10,
1796
+ runSpacing: 10,
1797
+ children: <Widget>[
1798
+ _MetaPill(
1799
+ label: androidStatus['bootstrapped'] == true
1800
+ ? 'SDK Ready'
1801
+ : 'Bootstrap Needed',
1802
+ icon: Icons.adb_outlined,
1803
+ ),
1804
+ _MetaPill(
1805
+ label: androidStatus['serial']?.toString().isNotEmpty == true
1806
+ ? androidStatus['serial'].toString()
1807
+ : 'No active serial',
1808
+ icon: Icons.phone_android_outlined,
1809
+ ),
1810
+ _MetaPill(
1811
+ label: '${androidDevices.length} device(s)',
1812
+ icon: Icons.devices_other_outlined,
1813
+ ),
1814
+ ],
1815
+ ),
1816
+ const SizedBox(height: 18),
1817
+ Wrap(
1818
+ spacing: 10,
1819
+ runSpacing: 10,
1820
+ children: <Widget>[
1821
+ FilledButton.icon(
1822
+ onPressed: controller.isRunningDeviceAction
1823
+ ? null
1824
+ : controller.startAndroidRuntime,
1825
+ icon: Icon(Icons.play_arrow_outlined),
1826
+ label: Text('Start Emulator'),
1827
+ ),
1828
+ OutlinedButton.icon(
1829
+ onPressed: controller.isRunningDeviceAction
1830
+ ? null
1831
+ : controller.stopAndroidRuntime,
1832
+ icon: Icon(Icons.stop_circle_outlined),
1833
+ label: Text('Stop'),
1834
+ ),
1835
+ OutlinedButton.icon(
1836
+ onPressed: controller.isRunningDeviceAction
1837
+ ? null
1838
+ : controller.screenshotAndroidRuntime,
1839
+ icon: Icon(Icons.photo_camera_outlined),
1840
+ label: Text('Screenshot'),
1841
+ ),
1842
+ OutlinedButton.icon(
1843
+ onPressed: controller.isRunningDeviceAction
1844
+ ? null
1845
+ : controller.dumpAndroidUiRuntime,
1846
+ icon: Icon(Icons.data_object_outlined),
1847
+ label: Text('Dump UI'),
1848
+ ),
1849
+ OutlinedButton.icon(
1850
+ onPressed: controller.isRunningDeviceAction
1851
+ ? null
1852
+ : controller.refreshAndroidApps,
1853
+ icon: Icon(Icons.apps_outlined),
1854
+ label: Text('Load Apps'),
1855
+ ),
1856
+ ],
1857
+ ),
1858
+ const SizedBox(height: 18),
1859
+ _DeviceFieldRow(
1860
+ children: <Widget>[
1861
+ _DeviceField(
1862
+ label: 'Package',
1863
+ child: TextField(controller: packageController),
1864
+ ),
1865
+ _DeviceField(
1866
+ label: 'Activity',
1867
+ child: TextField(controller: activityController),
1868
+ ),
1869
+ ],
1870
+ ),
1871
+ const SizedBox(height: 10),
1872
+ Wrap(
1873
+ spacing: 10,
1874
+ runSpacing: 10,
1875
+ children: <Widget>[
1876
+ OutlinedButton.icon(
1877
+ onPressed: controller.isRunningDeviceAction
1878
+ ? null
1879
+ : () => controller.openAndroidAppRuntime(
1880
+ packageName: packageController.text.trim(),
1881
+ activity: activityController.text.trim(),
1882
+ ),
1883
+ icon: Icon(Icons.apps),
1884
+ label: Text('Open App'),
1885
+ ),
1886
+ if (controller.androidInstalledApps.isNotEmpty)
1887
+ SizedBox(width: 1, height: 1, child: Container()),
1888
+ ],
1889
+ ),
1890
+ if (controller.androidInstalledApps.isNotEmpty) ...<Widget>[
1891
+ const SizedBox(height: 8),
1892
+ Wrap(
1893
+ spacing: 8,
1894
+ runSpacing: 8,
1895
+ children: controller.androidInstalledApps.take(10).map((appId) {
1896
+ return ActionChip(
1897
+ label: Text(appId),
1898
+ onPressed: () => packageController.text = appId,
1899
+ );
1900
+ }).toList(),
1901
+ ),
1902
+ ],
1903
+ const SizedBox(height: 18),
1904
+ _DeviceFieldRow(
1905
+ children: <Widget>[
1906
+ _DeviceField(
1907
+ label: 'Intent Action',
1908
+ child: TextField(controller: intentActionController),
1909
+ ),
1910
+ _DeviceField(
1911
+ label: 'Intent Data',
1912
+ child: TextField(controller: intentDataController),
1913
+ ),
1914
+ ],
1915
+ ),
1916
+ const SizedBox(height: 10),
1917
+ OutlinedButton.icon(
1918
+ onPressed: controller.isRunningDeviceAction
1919
+ ? null
1920
+ : () => controller.openAndroidIntentRuntime(
1921
+ action: intentActionController.text.trim(),
1922
+ dataUri: intentDataController.text.trim(),
1923
+ packageName: packageController.text.trim(),
1924
+ ),
1925
+ icon: Icon(Icons.route_outlined),
1926
+ label: Text('Open Intent'),
1927
+ ),
1928
+ const SizedBox(height: 18),
1929
+ _DeviceFieldRow(
1930
+ children: <Widget>[
1931
+ _DeviceField(
1932
+ label: 'Wait For Text',
1933
+ child: TextField(controller: waitTextController),
1934
+ ),
1935
+ _DeviceField(
1936
+ label: 'Key',
1937
+ child: TextField(controller: keyController),
1938
+ ),
1939
+ ],
1940
+ ),
1941
+ const SizedBox(height: 10),
1942
+ Wrap(
1943
+ spacing: 10,
1944
+ runSpacing: 10,
1945
+ children: <Widget>[
1946
+ OutlinedButton(
1947
+ onPressed: controller.isRunningDeviceAction
1948
+ ? null
1949
+ : () => controller.waitForAndroidRuntime(<String, dynamic>{
1950
+ 'text': waitTextController.text.trim(),
1951
+ 'timeoutMs': 20000,
1952
+ 'intervalMs': 1200,
1953
+ }),
1954
+ child: Text('Wait For UI'),
1955
+ ),
1956
+ OutlinedButton(
1957
+ onPressed: controller.isRunningDeviceAction
1958
+ ? null
1959
+ : () => controller.pressAndroidKeyRuntime(
1960
+ keyController.text.trim(),
1961
+ ),
1962
+ child: Text('Press Key'),
1963
+ ),
1964
+ ],
1965
+ ),
1966
+ const SizedBox(height: 18),
1967
+ _DeviceFieldRow(
1968
+ children: <Widget>[
1969
+ _DeviceField(
1970
+ label: 'Tap Text',
1971
+ child: TextField(controller: tapTextController),
1972
+ ),
1973
+ _DeviceField(
1974
+ label: 'Tap Description',
1975
+ child: TextField(controller: tapDescriptionController),
1976
+ ),
1977
+ ],
1978
+ ),
1979
+ const SizedBox(height: 10),
1980
+ _DeviceFieldRow(
1981
+ children: <Widget>[
1982
+ _DeviceField(
1983
+ label: 'Tap Resource Id',
1984
+ child: TextField(controller: tapResourceIdController),
1985
+ ),
1986
+ _DeviceField(
1987
+ label: 'Tap X / Y',
1988
+ child: Row(
1989
+ children: <Widget>[
1990
+ Expanded(child: TextField(controller: tapXController)),
1991
+ const SizedBox(width: 8),
1992
+ Expanded(child: TextField(controller: tapYController)),
1993
+ ],
1994
+ ),
1995
+ ),
1996
+ ],
1997
+ ),
1998
+ const SizedBox(height: 10),
1999
+ OutlinedButton.icon(
2000
+ onPressed: controller.isRunningDeviceAction
2001
+ ? null
2002
+ : () => controller.tapAndroidRuntime(<String, dynamic>{
2003
+ if (tapTextController.text.trim().isNotEmpty)
2004
+ 'text': tapTextController.text.trim(),
2005
+ if (tapDescriptionController.text.trim().isNotEmpty)
2006
+ 'description': tapDescriptionController.text.trim(),
2007
+ if (tapResourceIdController.text.trim().isNotEmpty)
2008
+ 'resourceId': tapResourceIdController.text.trim(),
2009
+ if (toInt(tapXController.text) != null)
2010
+ 'x': toInt(tapXController.text),
2011
+ if (toInt(tapYController.text) != null)
2012
+ 'y': toInt(tapYController.text),
2013
+ }),
2014
+ icon: Icon(Icons.touch_app_outlined),
2015
+ label: Text('Tap'),
2016
+ ),
2017
+ const SizedBox(height: 18),
2018
+ _DeviceFieldRow(
2019
+ children: <Widget>[
2020
+ _DeviceField(
2021
+ label: 'Type Text',
2022
+ child: TextField(controller: typeTextController),
2023
+ ),
2024
+ _DeviceField(
2025
+ label: 'Focus Field Text / Description',
2026
+ child: Row(
2027
+ children: <Widget>[
2028
+ Expanded(
2029
+ child: TextField(controller: typeFieldTextController),
2030
+ ),
2031
+ const SizedBox(width: 8),
2032
+ Expanded(
2033
+ child: TextField(
2034
+ controller: typeFieldDescriptionController,
2035
+ ),
2036
+ ),
2037
+ ],
2038
+ ),
2039
+ ),
2040
+ ],
2041
+ ),
2042
+ const SizedBox(height: 10),
2043
+ OutlinedButton.icon(
2044
+ onPressed: controller.isRunningDeviceAction
2045
+ ? null
2046
+ : () => controller.typeAndroidRuntime(<String, dynamic>{
2047
+ 'text': typeTextController.text,
2048
+ if (typeFieldTextController.text.trim().isNotEmpty)
2049
+ 'textSelector': typeFieldTextController.text.trim(),
2050
+ if (typeFieldDescriptionController.text.trim().isNotEmpty)
2051
+ 'description': typeFieldDescriptionController.text.trim(),
2052
+ 'pressEnter': true,
2053
+ }),
2054
+ icon: Icon(Icons.keyboard_outlined),
2055
+ label: Text('Type'),
2056
+ ),
2057
+ const SizedBox(height: 18),
2058
+ _DeviceFieldRow(
2059
+ children: <Widget>[
2060
+ _DeviceField(
2061
+ label: 'Swipe X1 / Y1',
2062
+ child: Row(
2063
+ children: <Widget>[
2064
+ Expanded(child: TextField(controller: swipeX1Controller)),
2065
+ const SizedBox(width: 8),
2066
+ Expanded(child: TextField(controller: swipeY1Controller)),
2067
+ ],
2068
+ ),
2069
+ ),
2070
+ _DeviceField(
2071
+ label: 'Swipe X2 / Y2',
2072
+ child: Row(
2073
+ children: <Widget>[
2074
+ Expanded(child: TextField(controller: swipeX2Controller)),
2075
+ const SizedBox(width: 8),
2076
+ Expanded(child: TextField(controller: swipeY2Controller)),
2077
+ ],
2078
+ ),
2079
+ ),
2080
+ ],
2081
+ ),
2082
+ const SizedBox(height: 10),
2083
+ OutlinedButton.icon(
2084
+ onPressed: controller.isRunningDeviceAction
2085
+ ? null
2086
+ : () => controller.swipeAndroidRuntime(<String, dynamic>{
2087
+ 'x1': toInt(swipeX1Controller.text),
2088
+ 'y1': toInt(swipeY1Controller.text),
2089
+ 'x2': toInt(swipeX2Controller.text),
2090
+ 'y2': toInt(swipeY2Controller.text),
2091
+ }),
2092
+ icon: Icon(Icons.swipe_outlined),
2093
+ label: Text('Swipe'),
2094
+ ),
2095
+ const SizedBox(height: 18),
2096
+ _RuntimePreview(
2097
+ title: 'Latest Android Screenshot',
2098
+ screenshotPath: controller.androidScreenshotPath,
2099
+ controller: controller,
2100
+ ),
2101
+ if (controller.androidUiPreview.isNotEmpty) ...<Widget>[
2102
+ const SizedBox(height: 14),
2103
+ Text(
2104
+ 'Latest UI dump preview',
2105
+ style: TextStyle(fontWeight: FontWeight.w700),
2106
+ ),
2107
+ const SizedBox(height: 10),
2108
+ ...controller.androidUiPreview.take(6).map((node) {
2109
+ final title = node['text']?.toString().trim().isNotEmpty == true
2110
+ ? node['text'].toString()
2111
+ : node['description']?.toString().trim().isNotEmpty == true
2112
+ ? node['description'].toString()
2113
+ : node['resourceId']?.toString().trim().isNotEmpty == true
2114
+ ? node['resourceId'].toString()
2115
+ : node['className']?.toString() ?? 'node';
2116
+ return Container(
2117
+ margin: const EdgeInsets.only(bottom: 8),
2118
+ padding: const EdgeInsets.all(12),
2119
+ decoration: BoxDecoration(
2120
+ color: _bgSecondary,
2121
+ borderRadius: BorderRadius.circular(10),
2122
+ border: Border.all(color: _border),
2123
+ ),
2124
+ child: Text(
2125
+ '$title\n${node['packageName'] ?? ''}',
2126
+ style: TextStyle(color: _textSecondary, height: 1.5),
2127
+ ),
2128
+ );
2129
+ }),
2130
+ ],
2131
+ if (controller.androidLastResult?.trim().isNotEmpty ??
2132
+ false) ...<Widget>[
2133
+ const SizedBox(height: 14),
2134
+ _ResultBlock(
2135
+ label: 'Last Android result',
2136
+ value: controller.androidLastResult!,
2137
+ ),
2138
+ ],
2139
+ ],
2140
+ );
2141
+ }
2142
+ }
2143
+
2144
+ class _DeviceFieldRow extends StatelessWidget {
2145
+ const _DeviceFieldRow({required this.children});
2146
+
2147
+ final List<Widget> children;
2148
+
2149
+ @override
2150
+ Widget build(BuildContext context) {
2151
+ if (children.isEmpty) {
2152
+ return const SizedBox.shrink();
2153
+ }
2154
+ final stacked = MediaQuery.sizeOf(context).width < 860;
2155
+ if (stacked) {
2156
+ return Column(
2157
+ children: children
2158
+ .map(
2159
+ (child) => Padding(
2160
+ padding: const EdgeInsets.only(bottom: 10),
2161
+ child: child,
2162
+ ),
2163
+ )
2164
+ .toList(),
2165
+ );
2166
+ }
2167
+ return Row(
2168
+ crossAxisAlignment: CrossAxisAlignment.start,
2169
+ children: <Widget>[
2170
+ for (var index = 0; index < children.length; index++) ...<Widget>[
2171
+ if (index > 0) const SizedBox(width: 12),
2172
+ Expanded(child: children[index]),
2173
+ ],
2174
+ ],
2175
+ );
2176
+ }
2177
+ }
2178
+
2179
+ class _DeviceField extends StatelessWidget {
2180
+ const _DeviceField({required this.label, required this.child});
2181
+
2182
+ final String label;
2183
+ final Widget child;
2184
+
2185
+ @override
2186
+ Widget build(BuildContext context) {
2187
+ return Column(
2188
+ crossAxisAlignment: CrossAxisAlignment.start,
2189
+ children: <Widget>[
2190
+ Text(
2191
+ label,
2192
+ style: TextStyle(
2193
+ color: _textSecondary,
2194
+ fontSize: 12,
2195
+ fontWeight: FontWeight.w700,
2196
+ ),
2197
+ ),
2198
+ const SizedBox(height: 8),
2199
+ child,
2200
+ ],
2201
+ );
2202
+ }
2203
+ }
2204
+
2205
+ class _RuntimePreview extends StatelessWidget {
2206
+ const _RuntimePreview({
2207
+ required this.title,
2208
+ required this.screenshotPath,
2209
+ required this.controller,
2210
+ });
2211
+
2212
+ final String title;
2213
+ final String? screenshotPath;
2214
+ final NeoAgentController controller;
2215
+
2216
+ @override
2217
+ Widget build(BuildContext context) {
2218
+ if (screenshotPath == null || screenshotPath!.isEmpty) {
2219
+ return _EmptyCard(
2220
+ title: title,
2221
+ subtitle:
2222
+ 'Run a screenshot-capable action to preview the live runtime.',
2223
+ );
2224
+ }
2225
+
2226
+ final uri = controller.resolveRuntimeAsset(screenshotPath!);
2227
+ return Card(
2228
+ color: _bgSecondary,
2229
+ child: Padding(
2230
+ padding: const EdgeInsets.all(14),
2231
+ child: Column(
2232
+ crossAxisAlignment: CrossAxisAlignment.start,
2233
+ children: <Widget>[
2234
+ Text(title, style: TextStyle(fontWeight: FontWeight.w700)),
2235
+ const SizedBox(height: 8),
2236
+ Text(
2237
+ screenshotPath!,
2238
+ style: TextStyle(color: _textSecondary, fontSize: 12),
2239
+ ),
2240
+ const SizedBox(height: 12),
2241
+ ClipRRect(
2242
+ borderRadius: BorderRadius.circular(12),
2243
+ child: Image.network(
2244
+ uri.toString(),
2245
+ headers: controller.authenticatedImageHeaders,
2246
+ fit: BoxFit.cover,
2247
+ errorBuilder: (context, _, __) {
2248
+ return Container(
2249
+ height: 220,
2250
+ color: _bgCard,
2251
+ alignment: Alignment.center,
2252
+ child: Text(
2253
+ 'Could not load preview image',
2254
+ style: TextStyle(color: _textSecondary),
2255
+ ),
2256
+ );
2257
+ },
2258
+ ),
2259
+ ),
2260
+ ],
2261
+ ),
2262
+ ),
2263
+ );
2264
+ }
2265
+ }
2266
+
2267
+ class _ResultBlock extends StatelessWidget {
2268
+ const _ResultBlock({required this.label, required this.value});
2269
+
2270
+ final String label;
2271
+ final String value;
2272
+
2273
+ @override
2274
+ Widget build(BuildContext context) {
2275
+ return Container(
2276
+ width: double.infinity,
2277
+ padding: const EdgeInsets.all(14),
2278
+ decoration: BoxDecoration(
2279
+ color: _bgSecondary,
2280
+ borderRadius: BorderRadius.circular(12),
2281
+ border: Border.all(color: _border),
2282
+ ),
2283
+ child: Column(
2284
+ crossAxisAlignment: CrossAxisAlignment.start,
2285
+ children: <Widget>[
2286
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2287
+ const SizedBox(height: 10),
2288
+ SelectableText(
2289
+ value,
2290
+ style: TextStyle(
2291
+ fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
2292
+ fontSize: 12,
2293
+ color: _textSecondary,
2294
+ height: 1.5,
2295
+ ),
2296
+ ),
2297
+ ],
2298
+ ),
2299
+ );
2300
+ }
2301
+ }