neoagent 2.3.1-beta.6 → 2.3.1-beta.61

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 (312) hide show
  1. package/.env.example +45 -0
  2. package/docs/capabilities.md +2 -2
  3. package/docs/configuration.md +6 -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 +111 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +171 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +804 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/auto/NeoAgentCarAppService.kt +114 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnection.kt +118 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnectionService.kt +86 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  30. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  31. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  32. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  33. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  35. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  36. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  37. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  38. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  39. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  43. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  44. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  45. package/flutter_app/android/app/src/main/res/values/arrays.xml +8 -0
  46. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  48. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  49. package/flutter_app/android/app/src/main/res/xml/automotive_app_desc.xml +4 -0
  50. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  51. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  52. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  53. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  54. package/flutter_app/android/build.gradle.kts +24 -0
  55. package/flutter_app/android/ci-release.keystore +0 -0
  56. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  57. package/flutter_app/android/gradle.properties +3 -0
  58. package/flutter_app/android/key.properties +4 -0
  59. package/flutter_app/android/settings.gradle.kts +26 -0
  60. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  62. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  63. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  64. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  65. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  66. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  67. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  68. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  69. package/flutter_app/lib/features/location/location_service.dart +119 -0
  70. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  71. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +571 -0
  72. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +275 -0
  73. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +366 -0
  74. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +57 -0
  75. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +218 -0
  76. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +46 -0
  77. package/flutter_app/lib/main.dart +103 -0
  78. package/flutter_app/lib/main_account_settings.dart +1250 -0
  79. package/flutter_app/lib/main_admin.dart +886 -0
  80. package/flutter_app/lib/main_app_shell.dart +2134 -0
  81. package/flutter_app/lib/main_chat.dart +3316 -0
  82. package/flutter_app/lib/main_controller.dart +6836 -0
  83. package/flutter_app/lib/main_devices.dart +2396 -0
  84. package/flutter_app/lib/main_integrations.dart +909 -0
  85. package/flutter_app/lib/main_launcher.dart +959 -0
  86. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  87. package/flutter_app/lib/main_models.dart +3539 -0
  88. package/flutter_app/lib/main_navigation.dart +188 -0
  89. package/flutter_app/lib/main_operations.dart +4851 -0
  90. package/flutter_app/lib/main_recordings.dart +920 -0
  91. package/flutter_app/lib/main_runtime.dart +838 -0
  92. package/flutter_app/lib/main_settings.dart +2040 -0
  93. package/flutter_app/lib/main_shared.dart +3147 -0
  94. package/flutter_app/lib/main_theme.dart +257 -0
  95. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  96. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  97. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  98. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  99. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  100. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  101. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  102. package/flutter_app/lib/src/android_auto_bridge.dart +59 -0
  103. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  104. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  105. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  106. package/flutter_app/lib/src/backend_client.dart +1837 -0
  107. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  108. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  109. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  110. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  111. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  112. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  113. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  114. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  115. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  116. package/flutter_app/lib/src/health_bridge.dart +136 -0
  117. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  118. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  119. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  120. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  121. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  122. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  123. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  124. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  125. package/flutter_app/lib/src/oauth_launcher_io.dart +95 -0
  126. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  127. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  128. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  129. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  130. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  131. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  132. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  133. package/flutter_app/lib/src/theme/palette.dart +81 -0
  134. package/flutter_app/lib/src/web_app_update_monitor.dart +17 -0
  135. package/flutter_app/lib/src/web_app_update_monitor_stub.dart +24 -0
  136. package/flutter_app/lib/src/web_app_update_monitor_web.dart +123 -0
  137. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  138. package/flutter_app/linux/CMakeLists.txt +128 -0
  139. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  140. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +47 -0
  141. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  142. package/flutter_app/linux/flutter/generated_plugins.cmake +32 -0
  143. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  144. package/flutter_app/linux/runner/main.cc +6 -0
  145. package/flutter_app/linux/runner/my_application.cc +144 -0
  146. package/flutter_app/linux/runner/my_application.h +18 -0
  147. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  148. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  149. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  150. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +44 -0
  151. package/flutter_app/macos/Podfile +42 -0
  152. package/flutter_app/macos/Podfile.lock +87 -0
  153. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  154. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  155. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  156. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  157. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  158. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  159. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  160. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  161. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  162. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  163. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  164. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  165. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  166. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  167. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  168. package/flutter_app/macos/Runner/Info.plist +36 -0
  169. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  170. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  171. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  172. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  173. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  174. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  175. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  176. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  177. package/flutter_app/patch_strings.py +12 -0
  178. package/flutter_app/pubspec.lock +1224 -0
  179. package/flutter_app/pubspec.yaml +57 -0
  180. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  181. package/flutter_app/test/recording_payloads_test.dart +53 -0
  182. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  183. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  184. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  185. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  186. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  187. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  188. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  189. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  190. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  191. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  192. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  193. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  194. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  195. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  196. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  197. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  198. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  199. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  200. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  201. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  202. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  203. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  204. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  205. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  206. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  207. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  208. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  209. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  210. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  211. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  212. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  213. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  214. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  215. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  216. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  217. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  218. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  219. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  220. package/flutter_app/web/favicon.png +0 -0
  221. package/flutter_app/web/favicon.svg +12 -0
  222. package/flutter_app/web/icons/Icon-192.png +0 -0
  223. package/flutter_app/web/icons/Icon-512.png +0 -0
  224. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  225. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  226. package/flutter_app/web/index.html +39 -0
  227. package/flutter_app/web/manifest.json +35 -0
  228. package/flutter_app/windows/CMakeLists.txt +108 -0
  229. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  230. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +50 -0
  231. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  232. package/flutter_app/windows/flutter/generated_plugins.cmake +36 -0
  233. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  234. package/flutter_app/windows/runner/Runner.rc +121 -0
  235. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  236. package/flutter_app/windows/runner/flutter_window.h +37 -0
  237. package/flutter_app/windows/runner/main.cpp +53 -0
  238. package/flutter_app/windows/runner/resource.h +16 -0
  239. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  240. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  241. package/flutter_app/windows/runner/utils.cpp +65 -0
  242. package/flutter_app/windows/runner/utils.h +19 -0
  243. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  244. package/flutter_app/windows/runner/win32_window.h +102 -0
  245. package/lib/install_helpers.js +31 -0
  246. package/lib/manager.js +201 -6
  247. package/package.json +5 -3
  248. package/server/db/database.js +125 -1
  249. package/server/http/middleware.js +55 -2
  250. package/server/http/routes.js +1 -0
  251. package/server/http/static.js +41 -1
  252. package/server/index.js +3 -0
  253. package/server/public/.last_build_id +1 -1
  254. package/server/public/assets/AssetManifest.bin +1 -1
  255. package/server/public/assets/AssetManifest.bin.json +1 -1
  256. package/server/public/assets/NOTICES +281 -1
  257. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  258. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  259. package/server/public/canvaskit/wimp.wasm +0 -0
  260. package/server/public/flutter_bootstrap.js +2 -2
  261. package/server/public/main.dart.js +86707 -82621
  262. package/server/routes/auth.js +26 -5
  263. package/server/routes/integrations.js +108 -1
  264. package/server/routes/memory.js +11 -2
  265. package/server/routes/settings.js +84 -2
  266. package/server/routes/wearable.js +67 -0
  267. package/server/services/ai/engine.js +3 -2
  268. package/server/services/ai/models.js +30 -0
  269. package/server/services/ai/providers/githubCopilot.js +97 -0
  270. package/server/services/ai/providers/openai.js +2 -1
  271. package/server/services/ai/providers/openaiCodex.js +31 -0
  272. package/server/services/ai/settings.js +20 -0
  273. package/server/services/ai/toolSelector.js +54 -1
  274. package/server/services/ai/tools.js +88 -5
  275. package/server/services/desktop/screenRecorder.js +65 -9
  276. package/server/services/integrations/env.js +5 -0
  277. package/server/services/integrations/figma/provider.js +1 -0
  278. package/server/services/integrations/github/common.js +106 -0
  279. package/server/services/integrations/github/provider.js +499 -0
  280. package/server/services/integrations/github/repos.js +1124 -0
  281. package/server/services/integrations/google/provider.js +1 -0
  282. package/server/services/integrations/manager.js +88 -12
  283. package/server/services/integrations/microsoft/provider.js +1 -0
  284. package/server/services/integrations/oauth_provider.js +25 -8
  285. package/server/services/integrations/provider_config_store.js +85 -0
  286. package/server/services/integrations/registry.js +4 -2
  287. package/server/services/integrations/spotify/provider.js +1 -0
  288. package/server/services/integrations/trello/provider.js +842 -0
  289. package/server/services/manager.js +46 -1
  290. package/server/services/mcp/client.js +120 -23
  291. package/server/services/memory/manager.js +39 -2
  292. package/server/services/messaging/access_policy.js +10 -0
  293. package/server/services/messaging/manager.js +49 -0
  294. package/server/services/messaging/meshtastic.js +277 -0
  295. package/server/services/messaging/meshtastic_env.js +40 -0
  296. package/server/services/messaging/meshtastic_protocol.js +530 -0
  297. package/server/services/messaging/meshtastic_protocol.test.js +33 -0
  298. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  299. package/server/services/tasks/runtime.js +1 -1
  300. package/server/services/voice/openaiClient.js +4 -1
  301. package/server/services/voice/openaiSpeech.js +6 -1
  302. package/server/services/voice/providers.js +144 -15
  303. package/server/services/voice/runtimeManager.js +156 -19
  304. package/server/services/voice/turnRunner.js +29 -9
  305. package/server/services/wearable/firmware_manifest.js +353 -0
  306. package/server/services/wearable/gateway.js +350 -0
  307. package/server/services/wearable/protocol.js +45 -0
  308. package/server/services/wearable/service.js +240 -0
  309. package/server/services/widgets/service.js +3 -0
  310. package/server/utils/local_secrets.js +56 -0
  311. package/server/utils/logger.js +37 -9
  312. package/server/services/integrations/home_assistant/provider.js +0 -350
@@ -0,0 +1,2040 @@
1
+ part of 'main.dart';
2
+
3
+ class SettingsPanel extends StatefulWidget {
4
+ const SettingsPanel({super.key, required this.controller});
5
+
6
+ final NeoAgentController controller;
7
+
8
+ @override
9
+ State<SettingsPanel> createState() => _SettingsPanelState();
10
+ }
11
+
12
+ const Map<String, List<String>> _voiceLiveModelsByProvider =
13
+ <String, List<String>>{
14
+ 'openai': <String>[
15
+ 'gpt-4o-realtime-preview',
16
+ 'gpt-4o-mini-realtime-preview',
17
+ ],
18
+ 'gemini': <String>['gemini-3.1-flash-live-preview'],
19
+ };
20
+
21
+ const Map<String, List<String>> _voiceLiveVoicesByProvider =
22
+ <String, List<String>>{
23
+ 'openai': <String>[
24
+ 'alloy',
25
+ 'ash',
26
+ 'ballad',
27
+ 'coral',
28
+ 'echo',
29
+ 'fable',
30
+ 'nova',
31
+ 'onyx',
32
+ 'sage',
33
+ 'shimmer',
34
+ 'verse',
35
+ 'marin',
36
+ 'cedar',
37
+ ],
38
+ 'gemini': <String>[
39
+ 'Kore',
40
+ 'Puck',
41
+ 'Charon',
42
+ 'Zephyr',
43
+ 'Leda',
44
+ 'Aoede',
45
+ 'Fenrir',
46
+ 'Orus',
47
+ 'Achernar',
48
+ 'Achird',
49
+ 'Algenib',
50
+ 'Algieba',
51
+ 'Alnilam',
52
+ 'Autonoe',
53
+ 'Callirrhoe',
54
+ 'Despina',
55
+ 'Enceladus',
56
+ 'Erinome',
57
+ 'Gacrux',
58
+ 'Iocaste',
59
+ 'Isonoe',
60
+ 'Laomedeia',
61
+ 'Larissa',
62
+ 'Lysithea',
63
+ 'Megaclite',
64
+ 'Mimosa',
65
+ 'Pulcherrima',
66
+ 'Rasalgethi',
67
+ 'Sadachbia',
68
+ 'Sulafat',
69
+ ],
70
+ };
71
+
72
+ class _SettingsPanelState extends State<SettingsPanel> {
73
+ late bool _headlessBrowser;
74
+ late String _browserBackend;
75
+ late bool _smarterSelector;
76
+ late Set<String> _enabledModels;
77
+ late String _defaultChatModel;
78
+ late String _defaultSubagentModel;
79
+ late String _defaultRecordingTranscriptionModel;
80
+ late String _defaultRecordingSummaryModel;
81
+ late String _fallbackModel;
82
+ late String _defaultSpeechModel;
83
+ late String _voiceLiveProvider;
84
+ late String _voiceLiveModel;
85
+ late String _voiceLiveVoice;
86
+ final Map<String, bool> _providerEnabled = <String, bool>{};
87
+ final Map<String, TextEditingController> _providerBaseUrlControllers =
88
+ <String, TextEditingController>{};
89
+ final Set<String> _expandedProviderIds = <String>{};
90
+
91
+ @override
92
+ void initState() {
93
+ super.initState();
94
+ _hydrate();
95
+ }
96
+
97
+ @override
98
+ void dispose() {
99
+ for (final controller in _providerBaseUrlControllers.values) {
100
+ controller.dispose();
101
+ }
102
+ super.dispose();
103
+ }
104
+
105
+ @override
106
+ void didUpdateWidget(covariant SettingsPanel oldWidget) {
107
+ super.didUpdateWidget(oldWidget);
108
+ if (oldWidget.controller.settings != widget.controller.settings ||
109
+ oldWidget.controller.aiProviders != widget.controller.aiProviders ||
110
+ oldWidget.controller.supportedModels !=
111
+ widget.controller.supportedModels) {
112
+ _hydrate();
113
+ }
114
+ }
115
+
116
+ void _hydrate() {
117
+ final controller = widget.controller;
118
+ final knownModels = controller.supportedModels
119
+ .map((model) => model.id)
120
+ .toSet();
121
+ final availableModels = controller.supportedModels
122
+ .where((model) => model.available)
123
+ .map((model) => model.id)
124
+ .toSet();
125
+ _headlessBrowser = controller.headlessBrowser;
126
+ _browserBackend = _normalizeBrowserBackend(controller.browserBackend);
127
+ _smarterSelector = controller.smarterSelector;
128
+ _enabledModels = controller.enabledModelIds
129
+ .where((id) => knownModels.contains(id))
130
+ .toSet();
131
+ if (_enabledModels.isEmpty && availableModels.isNotEmpty) {
132
+ _enabledModels = availableModels;
133
+ }
134
+ _defaultChatModel = controller.defaultChatModel;
135
+ _defaultSubagentModel = controller.defaultSubagentModel;
136
+ _defaultRecordingTranscriptionModel =
137
+ controller.defaultRecordingTranscriptionModel;
138
+ _defaultRecordingSummaryModel = controller.defaultRecordingSummaryModel;
139
+ _fallbackModel = controller.fallbackModel;
140
+ _defaultSpeechModel = controller.defaultSpeechModel;
141
+ _voiceLiveProvider = controller.voiceLiveProvider;
142
+ _voiceLiveModel = controller.voiceLiveModel;
143
+ _voiceLiveVoice = controller.voiceLiveVoice;
144
+ if (!_voiceLiveModelsByProvider.containsKey(_voiceLiveProvider)) {
145
+ _voiceLiveProvider = 'openai';
146
+ }
147
+ if (!(_voiceLiveModelsByProvider[_voiceLiveProvider]?.contains(
148
+ _voiceLiveModel,
149
+ ) ??
150
+ false)) {
151
+ _voiceLiveModel = _voiceLiveModelsByProvider[_voiceLiveProvider]!.first;
152
+ }
153
+ final liveVoiceOptions =
154
+ _voiceLiveVoicesByProvider[_voiceLiveProvider] ?? const <String>[];
155
+ if (liveVoiceOptions.isNotEmpty &&
156
+ !liveVoiceOptions.contains(_voiceLiveVoice)) {
157
+ _voiceLiveVoice = liveVoiceOptions.first;
158
+ }
159
+
160
+ final providerConfigs = controller.aiProviderConfigs;
161
+ final providerIds = <String>{
162
+ ...providerConfigs.keys,
163
+ ...controller.aiProviders.map((provider) => provider.id),
164
+ };
165
+
166
+ for (final providerId in providerIds) {
167
+ final config =
168
+ providerConfigs[providerId] ?? AiProviderConfig.empty(providerId);
169
+ _providerEnabled[providerId] = config.enabled;
170
+ _syncTextController(
171
+ _providerBaseUrlControllers,
172
+ providerId,
173
+ config.baseUrl,
174
+ );
175
+ }
176
+
177
+ _pruneControllers(_providerBaseUrlControllers, providerIds);
178
+ _providerEnabled.removeWhere((id, _) => !providerIds.contains(id));
179
+ }
180
+
181
+ String _normalizeBrowserBackend(String value) {
182
+ final normalized = value.trim().toLowerCase();
183
+ return normalized == 'extension' ? 'extension' : 'cloud';
184
+ }
185
+
186
+ @override
187
+ Widget build(BuildContext context) {
188
+ final controller = widget.controller;
189
+ final availableModels = controller.supportedModels
190
+ .where((model) => model.available)
191
+ .toList();
192
+ final routingModels = availableModels.isEmpty
193
+ ? controller.supportedModels
194
+ : availableModels;
195
+ final modelChoices = <DropdownMenuItem<String>>[
196
+ const DropdownMenuItem<String>(
197
+ value: 'auto',
198
+ child: Text('Smart Selector (Auto)'),
199
+ ),
200
+ ...routingModels.map(
201
+ (model) =>
202
+ DropdownMenuItem<String>(value: model.id, child: Text(model.label)),
203
+ ),
204
+ ];
205
+ final enabledSmartModels = _enabledModels
206
+ .where((id) => routingModels.any((model) => model.id == id))
207
+ .length;
208
+
209
+ return ListView(
210
+ padding: _pagePadding(context),
211
+ children: <Widget>[
212
+ _PageTitle(
213
+ title: 'Settings',
214
+ subtitle:
215
+ 'Workspace, models, recording, update, and diagnostics controls.',
216
+ trailing: FilledButton.icon(
217
+ onPressed: controller.isSavingSettings
218
+ ? null
219
+ : () => controller.saveSettings(
220
+ headlessBrowser: _headlessBrowser,
221
+ browserBackend: _browserBackend == 'extension'
222
+ ? 'extension'
223
+ : controller.cloudBrowserBackend,
224
+ smarterSelector: _smarterSelector,
225
+ enabledModels: _enabledModels.toList(),
226
+ defaultChatModel: _defaultChatModel,
227
+ defaultSubagentModel: _defaultSubagentModel,
228
+ defaultRecordingTranscriptionProvider: 'deepgram',
229
+ defaultRecordingTranscriptionModel:
230
+ _defaultRecordingTranscriptionModel,
231
+ defaultRecordingSummaryProvider: _providerForSelectedModel(
232
+ _defaultRecordingSummaryModel,
233
+ controller.supportedModels,
234
+ ),
235
+ defaultRecordingSummaryModel: _defaultRecordingSummaryModel,
236
+ fallbackModel: _fallbackModel,
237
+ defaultSpeechModel: _defaultSpeechModel,
238
+ voiceSttProvider: controller.voiceSttProvider,
239
+ voiceSttModel: controller.voiceSttModel,
240
+ voiceTtsProvider: controller.voiceTtsProvider,
241
+ voiceTtsModel: controller.voiceTtsModel,
242
+ voiceTtsVoice: controller.voiceTtsVoice,
243
+ voiceRuntimeMode: 'live',
244
+ voiceLiveProvider: _voiceLiveProvider,
245
+ voiceLiveModel: _voiceLiveModel,
246
+ voiceLiveVoice: _voiceLiveVoice,
247
+ aiProviderConfigs: _buildProviderPayload(),
248
+ ),
249
+ style: FilledButton.styleFrom(backgroundColor: _accent),
250
+ icon: controller.isSavingSettings
251
+ ? const SizedBox.square(
252
+ dimension: 16,
253
+ child: CircularProgressIndicator(
254
+ strokeWidth: 2,
255
+ color: Colors.white,
256
+ ),
257
+ )
258
+ : Icon(Icons.save_outlined),
259
+ label: Text('Save'),
260
+ ),
261
+ ),
262
+ if (controller.errorMessage != null) ...<Widget>[
263
+ _InlineError(message: controller.errorMessage!),
264
+ const SizedBox(height: 16),
265
+ ],
266
+ _buildSettingsOverview(controller, availableModels.length),
267
+ const SizedBox(height: 16),
268
+ _buildWorkspaceSection(controller),
269
+ const SizedBox(height: 16),
270
+ _buildModelsSection(
271
+ controller: controller,
272
+ modelChoices: modelChoices,
273
+ routingModels: routingModels,
274
+ availableModels: availableModels,
275
+ enabledSmartModels: enabledSmartModels,
276
+ ),
277
+ const SizedBox(height: 16),
278
+ _buildVoiceAndRecordingSection(
279
+ controller: controller,
280
+ modelChoices: modelChoices,
281
+ routingModels: routingModels,
282
+ ),
283
+ const SizedBox(height: 16),
284
+ if (_supportsDesktopShell) ...<Widget>[
285
+ _buildDesktopSection(controller),
286
+ const SizedBox(height: 16),
287
+ ],
288
+ _buildUpdatesSection(controller),
289
+ const SizedBox(height: 16),
290
+ _buildDiagnosticsSection(controller),
291
+ ],
292
+ );
293
+ }
294
+
295
+ Widget _buildSettingsOverview(
296
+ NeoAgentController controller,
297
+ int availableModelCount,
298
+ ) {
299
+ final platformLabel = kIsWeb ? 'Web' : defaultTargetPlatform.name;
300
+ return Card(
301
+ child: Padding(
302
+ padding: const EdgeInsets.all(20),
303
+ child: Column(
304
+ crossAxisAlignment: CrossAxisAlignment.start,
305
+ children: <Widget>[
306
+ const _SectionTitle('Overview'),
307
+ const SizedBox(height: 10),
308
+ Text(
309
+ 'Configure workspace behavior, then models, recording defaults, and updates.',
310
+ style: TextStyle(color: _textSecondary, height: 1.45),
311
+ ),
312
+ const SizedBox(height: 14),
313
+ Wrap(
314
+ spacing: 10,
315
+ runSpacing: 10,
316
+ children: <Widget>[
317
+ _MetaPill(
318
+ icon: Icons.devices_outlined,
319
+ label:
320
+ 'Platform ${platformLabel[0].toUpperCase()}${platformLabel.substring(1)}',
321
+ ),
322
+ _MetaPill(
323
+ icon: Icons.memory_outlined,
324
+ label: '$availableModelCount models ready',
325
+ ),
326
+ _MetaPill(
327
+ icon: Icons.hub_outlined,
328
+ label: '${controller.aiProviders.length} providers',
329
+ ),
330
+ _MetaPill(
331
+ icon: Icons.auto_awesome_outlined,
332
+ label: _smarterSelector
333
+ ? 'Smart selector on'
334
+ : 'Manual routing',
335
+ ),
336
+ if (_supportsDesktopShell)
337
+ _MetaPill(
338
+ icon: Icons.desktop_windows_outlined,
339
+ label: controller.desktopCompanionEnabled
340
+ ? 'Desktop companion enabled'
341
+ : 'Desktop-only controls available',
342
+ ),
343
+ ],
344
+ ),
345
+ const SizedBox(height: 14),
346
+ Align(
347
+ alignment: Alignment.centerLeft,
348
+ child: OutlinedButton.icon(
349
+ onPressed: controller.reopenOnboarding,
350
+ style: OutlinedButton.styleFrom(
351
+ visualDensity: VisualDensity.compact,
352
+ padding: const EdgeInsets.symmetric(
353
+ horizontal: 12,
354
+ vertical: 10,
355
+ ),
356
+ ),
357
+ icon: const Icon(Icons.replay_rounded, size: 18),
358
+ label: const Text('Redo onboarding'),
359
+ ),
360
+ ),
361
+ ],
362
+ ),
363
+ ),
364
+ );
365
+ }
366
+
367
+ Widget _buildWorkspaceSection(NeoAgentController controller) {
368
+ return Card(
369
+ child: Padding(
370
+ padding: const EdgeInsets.all(20),
371
+ child: Column(
372
+ crossAxisAlignment: CrossAxisAlignment.start,
373
+ children: <Widget>[
374
+ const _SectionTitle('Workspace'),
375
+ const SizedBox(height: 10),
376
+ Text(
377
+ 'Controls for how the app runs on this device and in the browser.',
378
+ style: TextStyle(color: _textSecondary, height: 1.45),
379
+ ),
380
+ const SizedBox(height: 16),
381
+ Text(
382
+ 'Browser Runtime',
383
+ style: TextStyle(
384
+ fontWeight: FontWeight.w700,
385
+ color: _textPrimary,
386
+ ),
387
+ ),
388
+ const SizedBox(height: 12),
389
+ _SettingToggle(
390
+ title: 'Run browser headless',
391
+ subtitle:
392
+ 'Keep browser automation off-screen when visible windows are not needed.',
393
+ value: _headlessBrowser,
394
+ onChanged: (value) => setState(() => _headlessBrowser = value),
395
+ ),
396
+ const SizedBox(height: 12),
397
+ DropdownButtonFormField<String>(
398
+ initialValue: _browserBackend,
399
+ decoration: const InputDecoration(
400
+ labelText: 'Browser backend',
401
+ helperText:
402
+ 'Cloud uses this deployment. Extension uses a paired Chrome browser.',
403
+ ),
404
+ items: const <DropdownMenuItem<String>>[
405
+ DropdownMenuItem<String>(
406
+ value: 'cloud',
407
+ child: Text('Cloud (local)'),
408
+ ),
409
+ DropdownMenuItem<String>(
410
+ value: 'extension',
411
+ child: Text('Chrome extension'),
412
+ ),
413
+ ],
414
+ onChanged: (value) {
415
+ if (value != null) {
416
+ setState(() => _browserBackend = value);
417
+ }
418
+ },
419
+ ),
420
+ const SizedBox(height: 10),
421
+ Text(
422
+ _browserBackend == 'extension'
423
+ ? (controller.browserExtensionConnected
424
+ ? 'Chrome extension connected.'
425
+ : 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
426
+ : controller.cloudBrowserBackend == 'vm'
427
+ ? "Cloud uses this deployment's isolated VM browser runtime."
428
+ : "Cloud uses this deployment's local host browser runtime.",
429
+ style: TextStyle(color: _textSecondary, height: 1.4),
430
+ ),
431
+ const SizedBox(height: 10),
432
+ Wrap(
433
+ spacing: 10,
434
+ runSpacing: 10,
435
+ children: <Widget>[
436
+ OutlinedButton.icon(
437
+ onPressed: controller.downloadBrowserExtension,
438
+ icon: Icon(Icons.download_outlined),
439
+ label: Text('Download extension'),
440
+ ),
441
+ OutlinedButton.icon(
442
+ onPressed: controller.refreshBrowserExtensionStatus,
443
+ icon: Icon(Icons.sync),
444
+ label: Text('Refresh status'),
445
+ ),
446
+ ],
447
+ ),
448
+ const Divider(height: 32),
449
+ Text(
450
+ 'Routing Behavior',
451
+ style: TextStyle(
452
+ fontWeight: FontWeight.w700,
453
+ color: _textPrimary,
454
+ ),
455
+ ),
456
+ const SizedBox(height: 12),
457
+ _SettingToggle(
458
+ title: 'Smart model selection',
459
+ subtitle:
460
+ 'Automatically choose the best enabled model for each task type.',
461
+ value: _smarterSelector,
462
+ onChanged: (value) => setState(() => _smarterSelector = value),
463
+ ),
464
+ ],
465
+ ),
466
+ ),
467
+ );
468
+ }
469
+
470
+ Widget _buildModelsSection({
471
+ required NeoAgentController controller,
472
+ required List<DropdownMenuItem<String>> modelChoices,
473
+ required List<ModelMeta> routingModels,
474
+ required List<ModelMeta> availableModels,
475
+ required int enabledSmartModels,
476
+ }) {
477
+ return Card(
478
+ child: Padding(
479
+ padding: const EdgeInsets.all(20),
480
+ child: Column(
481
+ crossAxisAlignment: CrossAxisAlignment.start,
482
+ children: <Widget>[
483
+ const _SectionTitle('Models'),
484
+ const SizedBox(height: 10),
485
+ Text(
486
+ 'Enable providers, then choose defaults for chat, agents, fallback behavior, and smart routing.',
487
+ style: TextStyle(color: _textSecondary, height: 1.45),
488
+ ),
489
+ const SizedBox(height: 16),
490
+ Text(
491
+ 'Providers',
492
+ style: TextStyle(
493
+ fontWeight: FontWeight.w700,
494
+ color: _textPrimary,
495
+ ),
496
+ ),
497
+ const SizedBox(height: 14),
498
+ if (controller.aiProviders.isEmpty)
499
+ Text(
500
+ 'Provider metadata is unavailable on this server version.',
501
+ style: TextStyle(color: _textSecondary),
502
+ )
503
+ else
504
+ LayoutBuilder(
505
+ builder: (context, constraints) {
506
+ final compact = constraints.maxWidth < 960;
507
+ final cardWidth = compact
508
+ ? constraints.maxWidth
509
+ : (constraints.maxWidth - 16) / 2;
510
+ return Wrap(
511
+ spacing: 16,
512
+ runSpacing: 16,
513
+ children: controller.aiProviders
514
+ .where(
515
+ (provider) =>
516
+ provider.available ||
517
+ _providerEnabled[provider.id] == true ||
518
+ controller
519
+ .aiProviderConfigs[provider.id]
520
+ ?.enabled ==
521
+ true,
522
+ )
523
+ .map((provider) {
524
+ return SizedBox(
525
+ width: cardWidth,
526
+ child: _AiProviderCard(
527
+ provider: provider,
528
+ enabled:
529
+ _providerEnabled[provider.id] ??
530
+ controller
531
+ .aiProviderConfigs[provider.id]
532
+ ?.enabled ??
533
+ true,
534
+ models: controller.supportedModels
535
+ .where(
536
+ (model) => model.provider == provider.id,
537
+ )
538
+ .toList(),
539
+ baseUrlController:
540
+ _providerBaseUrlControllers[provider.id]!,
541
+ expanded: _expandedProviderIds.contains(
542
+ provider.id,
543
+ ),
544
+ onEnabledChanged: (value) {
545
+ setState(() {
546
+ _providerEnabled[provider.id] = value;
547
+ });
548
+ },
549
+ onExpandToggle: () {
550
+ setState(() {
551
+ if (_expandedProviderIds.contains(
552
+ provider.id,
553
+ )) {
554
+ _expandedProviderIds.remove(provider.id);
555
+ } else {
556
+ _expandedProviderIds.add(provider.id);
557
+ }
558
+ });
559
+ },
560
+ ),
561
+ );
562
+ })
563
+ .toList(),
564
+ );
565
+ },
566
+ ),
567
+ const Divider(height: 32),
568
+ Text(
569
+ 'Default Routing',
570
+ style: TextStyle(
571
+ fontWeight: FontWeight.w700,
572
+ color: _textPrimary,
573
+ ),
574
+ ),
575
+ const SizedBox(height: 12),
576
+ if (routingModels.isNotEmpty)
577
+ LayoutBuilder(
578
+ builder: (context, constraints) {
579
+ final compact = constraints.maxWidth < 940;
580
+ final cardWidth = compact
581
+ ? constraints.maxWidth
582
+ : (constraints.maxWidth - 24) / 3;
583
+ return Wrap(
584
+ spacing: 12,
585
+ runSpacing: 12,
586
+ children: <Widget>[
587
+ SizedBox(
588
+ width: cardWidth,
589
+ child: _RoutingSelectCard(
590
+ label: 'Chat',
591
+ icon: Icons.chat_bubble_outline,
592
+ value: _ensureModelValue(
593
+ _defaultChatModel,
594
+ routingModels,
595
+ allowAuto: true,
596
+ ),
597
+ items: modelChoices,
598
+ onChanged: (value) {
599
+ if (value != null) {
600
+ setState(() => _defaultChatModel = value);
601
+ }
602
+ },
603
+ ),
604
+ ),
605
+ SizedBox(
606
+ width: cardWidth,
607
+ child: _RoutingSelectCard(
608
+ label: 'Sub-agent',
609
+ icon: Icons.bolt_outlined,
610
+ value: _ensureModelValue(
611
+ _defaultSubagentModel,
612
+ routingModels,
613
+ allowAuto: true,
614
+ ),
615
+ items: modelChoices,
616
+ onChanged: (value) {
617
+ if (value != null) {
618
+ setState(() => _defaultSubagentModel = value);
619
+ }
620
+ },
621
+ ),
622
+ ),
623
+ SizedBox(
624
+ width: cardWidth,
625
+ child: _RoutingSelectCard(
626
+ label: 'Fallback',
627
+ icon: Icons.shield_outlined,
628
+ value: _ensureModelValue(
629
+ _fallbackModel,
630
+ routingModels,
631
+ allowAuto: false,
632
+ ),
633
+ items: routingModels
634
+ .map(
635
+ (model) => DropdownMenuItem<String>(
636
+ value: model.id,
637
+ child: Text(model.label),
638
+ ),
639
+ )
640
+ .toList(),
641
+ onChanged: (value) {
642
+ if (value != null) {
643
+ setState(() => _fallbackModel = value);
644
+ }
645
+ },
646
+ ),
647
+ ),
648
+ ],
649
+ );
650
+ },
651
+ ),
652
+ const Divider(height: 32),
653
+ Text(
654
+ 'Smart Selector Pool',
655
+ style: TextStyle(
656
+ fontWeight: FontWeight.w700,
657
+ color: _textPrimary,
658
+ ),
659
+ ),
660
+ const SizedBox(height: 10),
661
+ Wrap(
662
+ spacing: 10,
663
+ runSpacing: 10,
664
+ children: controller.supportedModels.map((model) {
665
+ final selected = _enabledModels.contains(model.id);
666
+ return FilterChip(
667
+ label: Text(
668
+ model.available
669
+ ? model.label
670
+ : '${model.label} (${model.providerStatusLabel})',
671
+ ),
672
+ selected: selected,
673
+ selectedColor: _accentMuted,
674
+ checkmarkColor: _accent,
675
+ backgroundColor: _bgSecondary,
676
+ side: BorderSide(
677
+ color: model.available
678
+ ? _border
679
+ : _warning.withValues(alpha: 0.35),
680
+ ),
681
+ onSelected: model.available
682
+ ? (value) {
683
+ setState(() {
684
+ if (value) {
685
+ _enabledModels.add(model.id);
686
+ } else if (_enabledModels.length > 1) {
687
+ _enabledModels.remove(model.id);
688
+ }
689
+ });
690
+ }
691
+ : null,
692
+ );
693
+ }).toList(),
694
+ ),
695
+ const SizedBox(height: 14),
696
+ Text(
697
+ availableModels.isEmpty
698
+ ? 'Enable a ready provider above to unlock model routing.'
699
+ : '$enabledSmartModels models are currently eligible for smart routing.',
700
+ style: TextStyle(color: _textSecondary),
701
+ ),
702
+ ],
703
+ ),
704
+ ),
705
+ );
706
+ }
707
+
708
+ Widget _buildVoiceAndRecordingSection({
709
+ required NeoAgentController controller,
710
+ required List<DropdownMenuItem<String>> modelChoices,
711
+ required List<ModelMeta> routingModels,
712
+ }) {
713
+ final liveVoiceOptions =
714
+ _voiceLiveVoicesByProvider[_voiceLiveProvider] ?? const <String>[];
715
+ return Card(
716
+ child: Padding(
717
+ padding: const EdgeInsets.all(20),
718
+ child: Column(
719
+ crossAxisAlignment: CrossAxisAlignment.start,
720
+ children: <Widget>[
721
+ const _SectionTitle('Voice & Recording'),
722
+ const SizedBox(height: 10),
723
+ Text(
724
+ 'Defaults for transcription, summaries, and live voice.',
725
+ style: TextStyle(color: _textSecondary, height: 1.45),
726
+ ),
727
+ const SizedBox(height: 16),
728
+ Text(
729
+ 'Recording Defaults',
730
+ style: TextStyle(
731
+ fontWeight: FontWeight.w700,
732
+ color: _textPrimary,
733
+ ),
734
+ ),
735
+ const SizedBox(height: 12),
736
+ LayoutBuilder(
737
+ builder: (context, constraints) {
738
+ final compact = constraints.maxWidth < 940;
739
+ final cardWidth = compact
740
+ ? constraints.maxWidth
741
+ : (constraints.maxWidth - 12) / 2;
742
+ return Wrap(
743
+ spacing: 12,
744
+ runSpacing: 12,
745
+ children: <Widget>[
746
+ SizedBox(
747
+ width: cardWidth,
748
+ child: _RoutingSelectCard(
749
+ label: 'Recording Summary',
750
+ icon: Icons.summarize_outlined,
751
+ value: _ensureModelValue(
752
+ _defaultRecordingSummaryModel,
753
+ routingModels,
754
+ allowAuto: true,
755
+ ),
756
+ items: modelChoices,
757
+ onChanged: (value) {
758
+ if (value != null) {
759
+ setState(
760
+ () => _defaultRecordingSummaryModel = value,
761
+ );
762
+ }
763
+ },
764
+ ),
765
+ ),
766
+ SizedBox(
767
+ width: cardWidth,
768
+ child: _RoutingSelectCard(
769
+ label: 'Recording Transcription',
770
+ icon: Icons.hearing_outlined,
771
+ value: _defaultRecordingTranscriptionModel,
772
+ items: _recordingTranscriptionModelChoices(
773
+ _defaultRecordingTranscriptionModel,
774
+ ),
775
+ onChanged: (value) {
776
+ if (value != null) {
777
+ setState(() {
778
+ _defaultRecordingTranscriptionModel = value;
779
+ });
780
+ }
781
+ },
782
+ ),
783
+ ),
784
+ ],
785
+ );
786
+ },
787
+ ),
788
+ const Divider(height: 32),
789
+ Text(
790
+ 'Speech Processing',
791
+ style: TextStyle(
792
+ fontWeight: FontWeight.w700,
793
+ color: _textPrimary,
794
+ ),
795
+ ),
796
+ const SizedBox(height: 12),
797
+ LayoutBuilder(
798
+ builder: (context, constraints) {
799
+ final compact = constraints.maxWidth < 940;
800
+ final cardWidth = compact
801
+ ? constraints.maxWidth
802
+ : (constraints.maxWidth - 12) / 2;
803
+ return Wrap(
804
+ spacing: 12,
805
+ runSpacing: 12,
806
+ children: <Widget>[
807
+ SizedBox(
808
+ width: cardWidth,
809
+ child: _RoutingSelectCard(
810
+ label: 'Speech Model',
811
+ icon: Icons.record_voice_over_outlined,
812
+ value: _ensureModelValue(
813
+ _defaultSpeechModel,
814
+ routingModels,
815
+ allowAuto: true,
816
+ ),
817
+ items: modelChoices,
818
+ onChanged: (value) {
819
+ if (value != null) {
820
+ setState(() => _defaultSpeechModel = value);
821
+ }
822
+ },
823
+ ),
824
+ ),
825
+ ],
826
+ );
827
+ },
828
+ ),
829
+ const SizedBox(height: 10),
830
+ Text(
831
+ 'Used for the backend LLM that processes voice assistant and other speech-originated turns. This does not change the speech synthesis voice.',
832
+ style: TextStyle(color: _textSecondary, height: 1.4),
833
+ ),
834
+ const Divider(height: 32),
835
+ Text(
836
+ 'Live Voice',
837
+ style: TextStyle(
838
+ fontWeight: FontWeight.w700,
839
+ color: _textPrimary,
840
+ ),
841
+ ),
842
+ const SizedBox(height: 12),
843
+ LayoutBuilder(
844
+ builder: (context, constraints) {
845
+ final compact = constraints.maxWidth < 940;
846
+ final cardWidth = compact
847
+ ? constraints.maxWidth
848
+ : (constraints.maxWidth - 24) / 3;
849
+ return Wrap(
850
+ spacing: 12,
851
+ runSpacing: 12,
852
+ children: <Widget>[
853
+ SizedBox(
854
+ width: cardWidth,
855
+ child: _RoutingSelectCard(
856
+ label: 'Live Provider',
857
+ icon: Icons.call_outlined,
858
+ value: _voiceLiveProvider,
859
+ items: const <String>['openai', 'gemini']
860
+ .map(
861
+ (value) => DropdownMenuItem<String>(
862
+ value: value,
863
+ child: Text(value),
864
+ ),
865
+ )
866
+ .toList(),
867
+ onChanged: (value) {
868
+ if (value == null) return;
869
+ setState(() {
870
+ _voiceLiveProvider = value;
871
+ final modelOptions =
872
+ _voiceLiveModelsByProvider[_voiceLiveProvider] ??
873
+ const <String>[];
874
+ if (!modelOptions.contains(_voiceLiveModel) &&
875
+ modelOptions.isNotEmpty) {
876
+ _voiceLiveModel = modelOptions.first;
877
+ }
878
+ final voiceOptions =
879
+ _voiceLiveVoicesByProvider[_voiceLiveProvider] ??
880
+ const <String>[];
881
+ if (voiceOptions.isNotEmpty &&
882
+ !voiceOptions.contains(_voiceLiveVoice)) {
883
+ _voiceLiveVoice = voiceOptions.first;
884
+ }
885
+ });
886
+ },
887
+ ),
888
+ ),
889
+ SizedBox(
890
+ width: cardWidth,
891
+ child: _RoutingSelectCard(
892
+ label: 'Live Model',
893
+ icon: Icons.speed_outlined,
894
+ value: _voiceLiveModel,
895
+ items:
896
+ (_voiceLiveModelsByProvider[_voiceLiveProvider] ??
897
+ const <String>[])
898
+ .map(
899
+ (value) => DropdownMenuItem<String>(
900
+ value: value,
901
+ child: Text(value),
902
+ ),
903
+ )
904
+ .toList(),
905
+ onChanged: (value) {
906
+ if (value != null) {
907
+ setState(() => _voiceLiveModel = value);
908
+ }
909
+ },
910
+ ),
911
+ ),
912
+ if (liveVoiceOptions.isNotEmpty)
913
+ SizedBox(
914
+ width: cardWidth,
915
+ child: _RoutingSelectCard(
916
+ label: 'Live Voice',
917
+ icon: Icons.graphic_eq_outlined,
918
+ value: _voiceLiveVoice,
919
+ items: liveVoiceOptions
920
+ .map(
921
+ (value) => DropdownMenuItem<String>(
922
+ value: value,
923
+ child: Text(value),
924
+ ),
925
+ )
926
+ .toList(),
927
+ onChanged: (value) {
928
+ if (value != null) {
929
+ setState(() => _voiceLiveVoice = value);
930
+ }
931
+ },
932
+ ),
933
+ ),
934
+ ],
935
+ );
936
+ },
937
+ ),
938
+ ],
939
+ ),
940
+ ),
941
+ );
942
+ }
943
+
944
+ Widget _buildDesktopSection(NeoAgentController controller) {
945
+ return Card(
946
+ child: Padding(
947
+ padding: const EdgeInsets.all(20),
948
+ child: Column(
949
+ crossAxisAlignment: CrossAxisAlignment.start,
950
+ children: <Widget>[
951
+ const _SectionTitle('Desktop'),
952
+ const SizedBox(height: 10),
953
+ Text(
954
+ 'Desktop-only recording and companion controls for this computer.',
955
+ style: TextStyle(color: _textSecondary, height: 1.45),
956
+ ),
957
+ const SizedBox(height: 16),
958
+ Text(
959
+ 'Local App Behavior',
960
+ style: TextStyle(
961
+ fontWeight: FontWeight.w700,
962
+ color: _textPrimary,
963
+ ),
964
+ ),
965
+ const SizedBox(height: 12),
966
+ SwitchListTile.adaptive(
967
+ value: controller.desktopAskOnClose,
968
+ contentPadding: EdgeInsets.zero,
969
+ title: Text('Ask before closing to background'),
970
+ subtitle: Text(
971
+ 'Prompt for whether NeoAgent should stay resident in the tray when the main window closes.',
972
+ style: TextStyle(color: _textSecondary),
973
+ ),
974
+ onChanged: (value) => controller.setDesktopClosePreference(
975
+ askOnClose: value,
976
+ keepRunningOnClose: controller.desktopKeepRunningOnClose,
977
+ ),
978
+ ),
979
+ SwitchListTile.adaptive(
980
+ value: controller.desktopAutoShowFloatingToolbar,
981
+ contentPadding: EdgeInsets.zero,
982
+ title: Text('Auto-show floating toolbar'),
983
+ subtitle: Text(
984
+ 'Open the compact recording bar automatically whenever a desktop studio session starts.',
985
+ style: TextStyle(color: _textSecondary),
986
+ ),
987
+ onChanged: controller.setDesktopAutoShowFloatingToolbar,
988
+ ),
989
+ SwitchListTile.adaptive(
990
+ value: controller.desktopAssistantHotkeyEnabled,
991
+ contentPadding: EdgeInsets.zero,
992
+ title: Text('Reserve assistant hotkey'),
993
+ subtitle: Text(
994
+ 'Register $_desktopAssistantHotkeyLabel so the desktop shell is ready for the upcoming voice assistant summon flow.',
995
+ style: TextStyle(color: _textSecondary),
996
+ ),
997
+ onChanged: controller.recordingRuntime.supportsGlobalHotkeys
998
+ ? controller.setDesktopAssistantHotkeyEnabled
999
+ : null,
1000
+ ),
1001
+ const SizedBox(height: 12),
1002
+ Wrap(
1003
+ spacing: 10,
1004
+ runSpacing: 10,
1005
+ children: <Widget>[
1006
+ _RecordingPermissionBadge(
1007
+ label: 'Microphone',
1008
+ state: controller.recordingRuntime.microphonePermission,
1009
+ ),
1010
+ _RecordingPermissionBadge(
1011
+ label: 'System audio',
1012
+ state: controller.recordingRuntime.systemAudioPermission,
1013
+ ),
1014
+ ],
1015
+ ),
1016
+ const Divider(height: 32),
1017
+ Text(
1018
+ 'Companion Mode',
1019
+ style: TextStyle(
1020
+ fontWeight: FontWeight.w700,
1021
+ color: _textPrimary,
1022
+ ),
1023
+ ),
1024
+ const SizedBox(height: 12),
1025
+ SwitchListTile.adaptive(
1026
+ value: controller.desktopCompanionEnabled,
1027
+ contentPadding: EdgeInsets.zero,
1028
+ title: Text('Enable Companion Mode on this computer'),
1029
+ subtitle: Text(
1030
+ 'Expose this signed-in desktop app as a controllable companion device without a separate pairing flow.',
1031
+ style: TextStyle(color: _textSecondary),
1032
+ ),
1033
+ onChanged: controller.setDesktopCompanionEnabled,
1034
+ ),
1035
+ SwitchListTile.adaptive(
1036
+ value: controller.desktopCompanionPaused,
1037
+ contentPadding: EdgeInsets.zero,
1038
+ title: Text('Pause Companion Mode'),
1039
+ subtitle: Text(
1040
+ 'Keep the device registered but reject remote control commands locally until resumed.',
1041
+ style: TextStyle(color: _textSecondary),
1042
+ ),
1043
+ onChanged: controller.desktopCompanionEnabled
1044
+ ? controller.setDesktopCompanionPaused
1045
+ : null,
1046
+ ),
1047
+ const SizedBox(height: 12),
1048
+ TextFormField(
1049
+ initialValue: controller.desktopCompanionLabel,
1050
+ enabled: controller.desktopCompanionEnabled,
1051
+ decoration: const InputDecoration(
1052
+ labelText: 'Companion device label',
1053
+ hintText: 'My workstation',
1054
+ prefixIcon: Icon(Icons.edit_outlined),
1055
+ ),
1056
+ onFieldSubmitted: controller.setDesktopCompanionLabel,
1057
+ ),
1058
+ const SizedBox(height: 14),
1059
+ Wrap(
1060
+ spacing: 10,
1061
+ runSpacing: 10,
1062
+ children: <Widget>[
1063
+ _DotStatus(
1064
+ label: controller.desktopCompanionConnected
1065
+ ? 'Connected'
1066
+ : controller.desktopCompanionConnecting
1067
+ ? 'Connecting'
1068
+ : 'Disconnected',
1069
+ color: controller.desktopCompanionConnected
1070
+ ? _success
1071
+ : controller.desktopCompanionConnecting
1072
+ ? _accent
1073
+ : _warning,
1074
+ ),
1075
+ _DotStatus(
1076
+ label: controller.desktopCompanionPaused ? 'Paused' : 'Ready',
1077
+ color: controller.desktopCompanionPaused
1078
+ ? _warning
1079
+ : _success,
1080
+ ),
1081
+ ],
1082
+ ),
1083
+ if (controller.desktopCompanionErrorMessage
1084
+ case final message?) ...<Widget>[
1085
+ const SizedBox(height: 12),
1086
+ _InlineError(message: message),
1087
+ ],
1088
+ const SizedBox(height: 14),
1089
+ Builder(
1090
+ builder: (context) {
1091
+ final status = controller.desktopCompanionStatus;
1092
+ final permissionsRaw = status['permissions'];
1093
+ final permissions = permissionsRaw is Map
1094
+ ? permissionsRaw.map(
1095
+ (key, value) => MapEntry(
1096
+ key.toString(),
1097
+ value?.toString() ?? 'unknown',
1098
+ ),
1099
+ )
1100
+ : const <String, String>{};
1101
+ final screenCaptureState =
1102
+ permissions['screenCapture'] ?? 'unknown';
1103
+ final inputControlState =
1104
+ permissions['inputControl'] ?? 'unknown';
1105
+ final accessibilityState =
1106
+ permissions['accessibility'] ?? 'unknown';
1107
+ final grantHelp = switch (defaultTargetPlatform) {
1108
+ TargetPlatform.macOS =>
1109
+ 'Grant Screen Recording and Accessibility in System Settings, then press Re-check.',
1110
+ TargetPlatform.windows =>
1111
+ 'Grant capture and accessibility/input permissions in Windows Settings, then press Re-check.',
1112
+ TargetPlatform.linux =>
1113
+ 'Approve portal capture/input prompts and desktop accessibility access, then press Re-check.',
1114
+ TargetPlatform.android ||
1115
+ TargetPlatform.iOS ||
1116
+ TargetPlatform.fuchsia =>
1117
+ 'Desktop companion permission controls are unavailable on this platform.',
1118
+ };
1119
+ return Column(
1120
+ crossAxisAlignment: CrossAxisAlignment.start,
1121
+ children: <Widget>[
1122
+ Text(
1123
+ 'Permissions',
1124
+ style: TextStyle(
1125
+ fontWeight: FontWeight.w700,
1126
+ color: _textPrimary,
1127
+ ),
1128
+ ),
1129
+ const SizedBox(height: 8),
1130
+ Text(
1131
+ grantHelp,
1132
+ style: TextStyle(color: _textSecondary, height: 1.4),
1133
+ ),
1134
+ const SizedBox(height: 10),
1135
+ Wrap(
1136
+ spacing: 10,
1137
+ runSpacing: 10,
1138
+ children: <Widget>[
1139
+ _CompanionPermissionBadge(
1140
+ label: 'Screen capture',
1141
+ state: screenCaptureState,
1142
+ ),
1143
+ _CompanionPermissionBadge(
1144
+ label: 'Input control',
1145
+ state: inputControlState,
1146
+ ),
1147
+ _CompanionPermissionBadge(
1148
+ label: 'Accessibility',
1149
+ state: accessibilityState,
1150
+ ),
1151
+ ],
1152
+ ),
1153
+ const SizedBox(height: 12),
1154
+ Wrap(
1155
+ spacing: 10,
1156
+ runSpacing: 10,
1157
+ children: <Widget>[
1158
+ OutlinedButton.icon(
1159
+ onPressed: controller.desktopCompanionEnabled
1160
+ ? controller.refreshDesktopCompanionStatus
1161
+ : null,
1162
+ icon: Icon(Icons.sync_outlined),
1163
+ label: Text('Re-check permissions'),
1164
+ ),
1165
+ OutlinedButton.icon(
1166
+ onPressed: controller.desktopCompanionEnabled
1167
+ ? () => controller
1168
+ .openDesktopCompanionPermissionSettings(
1169
+ 'screenCapture',
1170
+ )
1171
+ : null,
1172
+ icon: Icon(Icons.monitor_outlined),
1173
+ label: Text('Open capture settings'),
1174
+ ),
1175
+ OutlinedButton.icon(
1176
+ onPressed: controller.desktopCompanionEnabled
1177
+ ? () => controller
1178
+ .openDesktopCompanionPermissionSettings(
1179
+ 'accessibility',
1180
+ )
1181
+ : null,
1182
+ icon: Icon(Icons.keyboard_command_key_outlined),
1183
+ label: Text('Open input/access settings'),
1184
+ ),
1185
+ OutlinedButton.icon(
1186
+ onPressed: controller.desktopCompanionEnabled
1187
+ ? controller.rotateDesktopCompanionIdentity
1188
+ : null,
1189
+ icon: Icon(Icons.refresh_outlined),
1190
+ label: Text('Reset Device Identity'),
1191
+ ),
1192
+ ],
1193
+ ),
1194
+ ],
1195
+ );
1196
+ },
1197
+ ),
1198
+ ],
1199
+ ),
1200
+ ),
1201
+ );
1202
+ }
1203
+
1204
+ Widget _buildUpdatesSection(NeoAgentController controller) {
1205
+ return Card(
1206
+ child: Padding(
1207
+ padding: const EdgeInsets.all(20),
1208
+ child: Column(
1209
+ crossAxisAlignment: CrossAxisAlignment.start,
1210
+ children: <Widget>[
1211
+ const _SectionTitle('Updates'),
1212
+ const SizedBox(height: 10),
1213
+ Text(
1214
+ 'Client and runtime update controls live here.',
1215
+ style: TextStyle(color: _textSecondary, height: 1.45),
1216
+ ),
1217
+ const SizedBox(height: 16),
1218
+ LayoutBuilder(
1219
+ builder: (context, constraints) {
1220
+ final compact = constraints.maxWidth < 720;
1221
+ final checkButton = FilledButton.icon(
1222
+ onPressed:
1223
+ controller.isCheckingAppUpdate ||
1224
+ !controller.appUpdaterConfigured
1225
+ ? null
1226
+ : () => controller.checkForAppUpdates(),
1227
+ style: FilledButton.styleFrom(backgroundColor: _accent),
1228
+ icon: controller.isCheckingAppUpdate
1229
+ ? const SizedBox.square(
1230
+ dimension: 16,
1231
+ child: CircularProgressIndicator(
1232
+ strokeWidth: 2,
1233
+ color: Colors.white,
1234
+ ),
1235
+ )
1236
+ : const Icon(Icons.sync),
1237
+ label: Text(
1238
+ controller.isCheckingAppUpdate
1239
+ ? 'Checking...'
1240
+ : 'Check now',
1241
+ ),
1242
+ );
1243
+ final appHeading = Text(
1244
+ 'Client App',
1245
+ style: TextStyle(
1246
+ fontWeight: FontWeight.w700,
1247
+ color: _textPrimary,
1248
+ ),
1249
+ );
1250
+ if (compact) {
1251
+ return Column(
1252
+ crossAxisAlignment: CrossAxisAlignment.start,
1253
+ children: <Widget>[
1254
+ appHeading,
1255
+ const SizedBox(height: 10),
1256
+ checkButton,
1257
+ ],
1258
+ );
1259
+ }
1260
+ return Row(
1261
+ children: <Widget>[
1262
+ Expanded(child: appHeading),
1263
+ checkButton,
1264
+ ],
1265
+ );
1266
+ },
1267
+ ),
1268
+ const SizedBox(height: 12),
1269
+ if (!controller.appUpdaterConfigured)
1270
+ Text(
1271
+ kIsWeb
1272
+ ? 'Client app update checks are disabled in the web app to avoid blocked browser-side GitHub requests.'
1273
+ : 'Client app updates are not configured for this build.',
1274
+ style: TextStyle(color: _textSecondary, height: 1.5),
1275
+ )
1276
+ else ...<Widget>[
1277
+ LayoutBuilder(
1278
+ builder: (context, constraints) {
1279
+ final compact = constraints.maxWidth < 780;
1280
+ final channelPicker = DropdownButtonFormField<String>(
1281
+ initialValue: controller.appUpdateChannel,
1282
+ decoration: const InputDecoration(
1283
+ labelText: 'App release channel',
1284
+ ),
1285
+ items: const <DropdownMenuItem<String>>[
1286
+ DropdownMenuItem<String>(
1287
+ value: 'stable',
1288
+ child: Text('Stable'),
1289
+ ),
1290
+ DropdownMenuItem<String>(
1291
+ value: 'beta',
1292
+ child: Text('Beta'),
1293
+ ),
1294
+ ],
1295
+ onChanged: (value) {
1296
+ if (value != null) {
1297
+ unawaited(controller.setAppUpdateChannel(value));
1298
+ }
1299
+ },
1300
+ );
1301
+ final autoCheck = SwitchListTile.adaptive(
1302
+ value: controller.appUpdateAutoCheckEnabled,
1303
+ contentPadding: EdgeInsets.zero,
1304
+ title: Text('Check automatically on launch'),
1305
+ subtitle: Text(
1306
+ 'This only checks GitHub Releases on startup. Installation still requires your confirmation.',
1307
+ style: TextStyle(color: _textSecondary),
1308
+ ),
1309
+ onChanged: controller.setAppUpdateAutoCheckEnabled,
1310
+ );
1311
+
1312
+ if (compact) {
1313
+ return Column(
1314
+ crossAxisAlignment: CrossAxisAlignment.start,
1315
+ children: <Widget>[
1316
+ channelPicker,
1317
+ const SizedBox(height: 10),
1318
+ autoCheck,
1319
+ ],
1320
+ );
1321
+ }
1322
+
1323
+ return Row(
1324
+ crossAxisAlignment: CrossAxisAlignment.start,
1325
+ children: <Widget>[
1326
+ Expanded(child: channelPicker),
1327
+ const SizedBox(width: 16),
1328
+ Expanded(child: autoCheck),
1329
+ ],
1330
+ );
1331
+ },
1332
+ ),
1333
+ const SizedBox(height: 8),
1334
+ Text(
1335
+ 'Installed: ${controller.installedAppVersion ?? 'Unknown'} | Channel: ${controller.appUpdateChannelLabel} | Last checked: ${controller.appUpdateLastCheckedLabel}',
1336
+ style: TextStyle(color: _textSecondary),
1337
+ ),
1338
+ const SizedBox(height: 6),
1339
+ Text(
1340
+ 'Source: ${app_release_updater.appUpdaterGithubOwner}/${app_release_updater.appUpdaterGithubRepo}${app_release_updater.appUpdaterGithubToken.trim().isNotEmpty ? ' (override active)' : ''}',
1341
+ style: TextStyle(color: _textSecondary),
1342
+ ),
1343
+ if (controller.appUpdateErrorMessage
1344
+ case final message?) ...<Widget>[
1345
+ const SizedBox(height: 12),
1346
+ _InlineError(message: message),
1347
+ ],
1348
+ if (controller.availableAppUpdate
1349
+ case final release?) ...<Widget>[
1350
+ const SizedBox(height: 16),
1351
+ Container(
1352
+ width: double.infinity,
1353
+ padding: const EdgeInsets.all(16),
1354
+ decoration: BoxDecoration(
1355
+ color: _bgSecondary,
1356
+ borderRadius: BorderRadius.circular(18),
1357
+ border: Border.all(color: _border),
1358
+ ),
1359
+ child: Column(
1360
+ crossAxisAlignment: CrossAxisAlignment.start,
1361
+ children: <Widget>[
1362
+ Wrap(
1363
+ spacing: 10,
1364
+ runSpacing: 10,
1365
+ children: <Widget>[
1366
+ _StatusPill(
1367
+ label: 'Update ${release.version}',
1368
+ color: release.channel == 'beta'
1369
+ ? _warning
1370
+ : _accent,
1371
+ ),
1372
+ _StatusPill(
1373
+ label: release.asset.name,
1374
+ color: _textSecondary,
1375
+ ),
1376
+ ],
1377
+ ),
1378
+ const SizedBox(height: 10),
1379
+ Text(
1380
+ '${release.title} · ${release.publishedLabel} · ${release.asset.sizeLabel}',
1381
+ style: TextStyle(color: _textSecondary),
1382
+ ),
1383
+ if (release.body.trim().isNotEmpty) ...<Widget>[
1384
+ const SizedBox(height: 14),
1385
+ ConstrainedBox(
1386
+ constraints: const BoxConstraints(maxHeight: 220),
1387
+ child: SingleChildScrollView(
1388
+ child: MarkdownBody(
1389
+ data: release.body,
1390
+ selectable: true,
1391
+ styleSheet: MarkdownStyleSheet(
1392
+ p: TextStyle(
1393
+ color: _textSecondary,
1394
+ height: 1.45,
1395
+ ),
1396
+ ),
1397
+ ),
1398
+ ),
1399
+ ),
1400
+ ],
1401
+ const SizedBox(height: 14),
1402
+ Wrap(
1403
+ spacing: 10,
1404
+ runSpacing: 10,
1405
+ children: <Widget>[
1406
+ FilledButton.icon(
1407
+ onPressed: controller.isOpeningAppUpdate
1408
+ ? null
1409
+ : controller.openAppUpdate,
1410
+ style: FilledButton.styleFrom(
1411
+ backgroundColor: _accent,
1412
+ ),
1413
+ icon: controller.isOpeningAppUpdate
1414
+ ? const SizedBox.square(
1415
+ dimension: 16,
1416
+ child: CircularProgressIndicator(
1417
+ strokeWidth: 2,
1418
+ color: Colors.white,
1419
+ ),
1420
+ )
1421
+ : const Icon(Icons.system_update_alt),
1422
+ label: Text(
1423
+ controller.isOpeningAppUpdate
1424
+ ? 'Opening...'
1425
+ : 'Download update',
1426
+ ),
1427
+ ),
1428
+ if (release.htmlUrl.trim().isNotEmpty)
1429
+ OutlinedButton.icon(
1430
+ onPressed: () {
1431
+ unawaited(
1432
+ widget.controller._oauthLauncher.openExternal(
1433
+ url: release.htmlUrl,
1434
+ label: 'release_notes',
1435
+ ),
1436
+ );
1437
+ },
1438
+ icon: const Icon(Icons.open_in_new),
1439
+ label: Text('View release'),
1440
+ ),
1441
+ ],
1442
+ ),
1443
+ ],
1444
+ ),
1445
+ ),
1446
+ ] else ...<Widget>[
1447
+ const SizedBox(height: 12),
1448
+ Text(
1449
+ controller.isCheckingAppUpdate
1450
+ ? 'Checking GitHub releases...'
1451
+ : controller.appUpdateLastCheckedAt == null
1452
+ ? 'Choose a channel, then check GitHub releases.'
1453
+ : 'No newer app release is available on the selected channel.',
1454
+ style: TextStyle(color: _textSecondary, height: 1.45),
1455
+ ),
1456
+ ],
1457
+ ],
1458
+ const Divider(height: 32),
1459
+ if (controller.updateStatus.allowSelfUpdate) ...<Widget>[
1460
+ LayoutBuilder(
1461
+ builder: (context, constraints) {
1462
+ final compact = constraints.maxWidth < 780;
1463
+ final channelPicker = DropdownButtonFormField<String>(
1464
+ initialValue: controller.updateStatus.releaseChannel,
1465
+ decoration: const InputDecoration(
1466
+ labelText: 'Runtime release channel',
1467
+ ),
1468
+ items: const <DropdownMenuItem<String>>[
1469
+ DropdownMenuItem<String>(
1470
+ value: 'stable',
1471
+ child: Text('Stable'),
1472
+ ),
1473
+ DropdownMenuItem<String>(
1474
+ value: 'beta',
1475
+ child: Text('Beta'),
1476
+ ),
1477
+ ],
1478
+ onChanged:
1479
+ controller.isSavingReleaseChannel ||
1480
+ controller.isTriggeringUpdate ||
1481
+ controller.updateStatus.state == 'running'
1482
+ ? null
1483
+ : (value) {
1484
+ if (value != null) {
1485
+ unawaited(controller.setReleaseChannel(value));
1486
+ }
1487
+ },
1488
+ );
1489
+
1490
+ final channelHelper = Text(
1491
+ controller.updateStatus.releaseChannel == 'beta'
1492
+ ? 'Beta follows preview releases.'
1493
+ : 'Stable follows production releases.',
1494
+ style: TextStyle(color: _textSecondary),
1495
+ );
1496
+
1497
+ if (compact) {
1498
+ return Column(
1499
+ crossAxisAlignment: CrossAxisAlignment.start,
1500
+ children: <Widget>[
1501
+ channelPicker,
1502
+ const SizedBox(height: 8),
1503
+ channelHelper,
1504
+ const SizedBox(height: 16),
1505
+ ],
1506
+ );
1507
+ }
1508
+
1509
+ return Padding(
1510
+ padding: const EdgeInsets.only(bottom: 16),
1511
+ child: Row(
1512
+ crossAxisAlignment: CrossAxisAlignment.start,
1513
+ children: <Widget>[
1514
+ Expanded(child: channelPicker),
1515
+ const SizedBox(width: 12),
1516
+ Expanded(child: channelHelper),
1517
+ ],
1518
+ ),
1519
+ );
1520
+ },
1521
+ ),
1522
+ LayoutBuilder(
1523
+ builder: (context, constraints) {
1524
+ final compact = constraints.maxWidth < 780;
1525
+ final runtimeTitle = Text(
1526
+ 'Runtime',
1527
+ style: TextStyle(
1528
+ fontWeight: FontWeight.w700,
1529
+ color: _textPrimary,
1530
+ ),
1531
+ );
1532
+ final updateButton = FilledButton.icon(
1533
+ onPressed:
1534
+ controller.isSavingReleaseChannel ||
1535
+ controller.isTriggeringUpdate ||
1536
+ controller.updateStatus.state == 'running'
1537
+ ? null
1538
+ : controller.triggerUpdate,
1539
+ style: FilledButton.styleFrom(backgroundColor: _accent),
1540
+ icon: controller.isTriggeringUpdate
1541
+ ? const SizedBox.square(
1542
+ dimension: 16,
1543
+ child: CircularProgressIndicator(
1544
+ strokeWidth: 2,
1545
+ color: Colors.white,
1546
+ ),
1547
+ )
1548
+ : Icon(Icons.system_update),
1549
+ label: Text('Update'),
1550
+ );
1551
+ if (compact) {
1552
+ return Column(
1553
+ crossAxisAlignment: CrossAxisAlignment.start,
1554
+ children: <Widget>[
1555
+ runtimeTitle,
1556
+ const SizedBox(height: 10),
1557
+ updateButton,
1558
+ ],
1559
+ );
1560
+ }
1561
+ return Row(
1562
+ children: <Widget>[
1563
+ Expanded(child: runtimeTitle),
1564
+ updateButton,
1565
+ ],
1566
+ );
1567
+ },
1568
+ ),
1569
+ ] else ...<Widget>[
1570
+ Text(
1571
+ 'Runtime',
1572
+ style: TextStyle(
1573
+ fontWeight: FontWeight.w700,
1574
+ color: _textPrimary,
1575
+ ),
1576
+ ),
1577
+ const SizedBox(height: 10),
1578
+ Text(
1579
+ 'Updates and release tracks are managed for this deployment.',
1580
+ style: TextStyle(color: _textSecondary),
1581
+ ),
1582
+ ],
1583
+ const SizedBox(height: 12),
1584
+ LayoutBuilder(
1585
+ builder: (context, constraints) {
1586
+ final compact = constraints.maxWidth < 760;
1587
+ final statusRow = Wrap(
1588
+ spacing: 10,
1589
+ runSpacing: 10,
1590
+ crossAxisAlignment: WrapCrossAlignment.center,
1591
+ children: <Widget>[
1592
+ _StatusPill(
1593
+ label: controller.updateStatus.badgeLabel,
1594
+ color: controller.updateStatus.badgeColor,
1595
+ ),
1596
+ _StatusPill(
1597
+ label: controller.updateStatus.releaseChannelLabel,
1598
+ color: controller.updateStatus.releaseChannel == 'beta'
1599
+ ? _warning
1600
+ : _accent,
1601
+ ),
1602
+ Text(
1603
+ controller.updateStatus.message,
1604
+ style: TextStyle(color: _textSecondary),
1605
+ ),
1606
+ Text('${controller.updateStatus.progress}%'),
1607
+ ],
1608
+ );
1609
+ if (compact) {
1610
+ return Column(
1611
+ crossAxisAlignment: CrossAxisAlignment.start,
1612
+ children: <Widget>[statusRow],
1613
+ );
1614
+ }
1615
+ return statusRow;
1616
+ },
1617
+ ),
1618
+ const SizedBox(height: 10),
1619
+ ClipRRect(
1620
+ borderRadius: BorderRadius.circular(999),
1621
+ child: LinearProgressIndicator(
1622
+ minHeight: 8,
1623
+ value: controller.updateStatus.progress / 100,
1624
+ backgroundColor: _bgSecondary,
1625
+ color: _accent,
1626
+ ),
1627
+ ),
1628
+ const SizedBox(height: 12),
1629
+ Text(controller.updateStatus.versionLine),
1630
+ ],
1631
+ ),
1632
+ ),
1633
+ );
1634
+ }
1635
+
1636
+ Widget _buildDiagnosticsSection(NeoAgentController controller) {
1637
+ return Card(
1638
+ child: Padding(
1639
+ padding: const EdgeInsets.all(20),
1640
+ child: Column(
1641
+ crossAxisAlignment: CrossAxisAlignment.start,
1642
+ children: <Widget>[
1643
+ Row(
1644
+ children: <Widget>[
1645
+ const _SectionTitle('Diagnostics'),
1646
+ const SizedBox(width: 8),
1647
+ Icon(Icons.info_outline, size: 16, color: _textSecondary),
1648
+ ],
1649
+ ),
1650
+ const SizedBox(height: 10),
1651
+ Text(
1652
+ 'Usage and health signals that help explain current runtime behavior without digging through logs first.',
1653
+ style: TextStyle(color: _textSecondary, height: 1.45),
1654
+ ),
1655
+ const SizedBox(height: 14),
1656
+ if (controller.tokenUsage == null)
1657
+ Text(
1658
+ 'Token usage unavailable on this server version.',
1659
+ style: TextStyle(color: _textSecondary),
1660
+ )
1661
+ else
1662
+ Column(
1663
+ crossAxisAlignment: CrossAxisAlignment.start,
1664
+ children: <Widget>[
1665
+ Text(
1666
+ 'Total: ${controller.tokenUsage!.totalTokensLabel} tokens across ${controller.tokenUsage!.totalRunsLabel} runs',
1667
+ ),
1668
+ const SizedBox(height: 6),
1669
+ Text(
1670
+ 'Last 7 days: ${controller.tokenUsage!.last7DaysTokensLabel} tokens in ${controller.tokenUsage!.last7DaysRunsLabel} runs',
1671
+ ),
1672
+ const SizedBox(height: 6),
1673
+ Text(
1674
+ 'Avg/run: ${controller.tokenUsage!.avgTokensPerRunLabel} tokens',
1675
+ ),
1676
+ ],
1677
+ ),
1678
+ ],
1679
+ ),
1680
+ ),
1681
+ );
1682
+ }
1683
+
1684
+ String _providerForSelectedModel(String modelId, List<ModelMeta> models) {
1685
+ if (modelId.trim().isEmpty || modelId == 'auto') {
1686
+ return 'auto';
1687
+ }
1688
+ for (final model in models) {
1689
+ if (model.id == modelId) {
1690
+ return model.provider.trim().isEmpty ? 'auto' : model.provider;
1691
+ }
1692
+ }
1693
+ return 'auto';
1694
+ }
1695
+
1696
+ Map<String, dynamic> _buildProviderPayload() {
1697
+ final providerIds = <String>{
1698
+ ...widget.controller.aiProviders.map((provider) => provider.id),
1699
+ ...widget.controller.aiProviderConfigs.keys,
1700
+ };
1701
+
1702
+ return <String, dynamic>{
1703
+ for (final providerId in providerIds)
1704
+ providerId: <String, dynamic>{
1705
+ 'enabled':
1706
+ _providerEnabled[providerId] ??
1707
+ widget.controller.aiProviderConfigs[providerId]?.enabled ??
1708
+ true,
1709
+ 'baseUrl': _providerBaseUrlControllers[providerId]?.text.trim() ?? '',
1710
+ },
1711
+ };
1712
+ }
1713
+
1714
+ void _syncTextController(
1715
+ Map<String, TextEditingController> controllers,
1716
+ String id,
1717
+ String value,
1718
+ ) {
1719
+ final controller = controllers.putIfAbsent(
1720
+ id,
1721
+ () => TextEditingController(text: value),
1722
+ );
1723
+ if (controller.text != value) {
1724
+ controller.text = value;
1725
+ }
1726
+ }
1727
+
1728
+ void _pruneControllers(
1729
+ Map<String, TextEditingController> controllers,
1730
+ Set<String> activeIds,
1731
+ ) {
1732
+ final staleIds = controllers.keys
1733
+ .where((id) => !activeIds.contains(id))
1734
+ .toList();
1735
+ for (final id in staleIds) {
1736
+ controllers.remove(id)?.dispose();
1737
+ }
1738
+ }
1739
+
1740
+ List<DropdownMenuItem<String>> _recordingTranscriptionModelChoices(
1741
+ String current,
1742
+ ) {
1743
+ const defaults = <String>['nova-3', 'nova-2-general'];
1744
+ final normalizedCurrent = current.trim();
1745
+ final values = <String>{...defaults};
1746
+ if (normalizedCurrent.isNotEmpty) {
1747
+ values.add(normalizedCurrent);
1748
+ }
1749
+ return values
1750
+ .map(
1751
+ (value) => DropdownMenuItem<String>(value: value, child: Text(value)),
1752
+ )
1753
+ .toList();
1754
+ }
1755
+ }
1756
+
1757
+ class _AiProviderCard extends StatelessWidget {
1758
+ const _AiProviderCard({
1759
+ required this.provider,
1760
+ required this.enabled,
1761
+ required this.expanded,
1762
+ required this.models,
1763
+ required this.baseUrlController,
1764
+ required this.onEnabledChanged,
1765
+ required this.onExpandToggle,
1766
+ });
1767
+
1768
+ final AiProviderMeta provider;
1769
+ final bool enabled;
1770
+ final bool expanded;
1771
+ final List<ModelMeta> models;
1772
+ final TextEditingController baseUrlController;
1773
+ final ValueChanged<bool> onEnabledChanged;
1774
+ final VoidCallback onExpandToggle;
1775
+
1776
+ @override
1777
+ Widget build(BuildContext context) {
1778
+ final availableCount = models.where((model) => model.available).length;
1779
+ final hasAdvancedFields = provider.supportsBaseUrl || models.isNotEmpty;
1780
+ return Container(
1781
+ padding: const EdgeInsets.all(16),
1782
+ decoration: BoxDecoration(
1783
+ color: _bgSecondary,
1784
+ borderRadius: BorderRadius.circular(18),
1785
+ border: Border.all(color: _borderLight),
1786
+ ),
1787
+ child: Column(
1788
+ crossAxisAlignment: CrossAxisAlignment.start,
1789
+ children: <Widget>[
1790
+ Row(
1791
+ crossAxisAlignment: CrossAxisAlignment.start,
1792
+ children: <Widget>[
1793
+ Container(
1794
+ width: 44,
1795
+ height: 44,
1796
+ decoration: BoxDecoration(
1797
+ color: _accentMuted,
1798
+ borderRadius: BorderRadius.circular(14),
1799
+ ),
1800
+ child: Icon(provider.icon, color: _accentHover),
1801
+ ),
1802
+ const SizedBox(width: 12),
1803
+ Expanded(
1804
+ child: Column(
1805
+ crossAxisAlignment: CrossAxisAlignment.start,
1806
+ children: <Widget>[
1807
+ Text(
1808
+ provider.label,
1809
+ style: TextStyle(
1810
+ fontSize: 16,
1811
+ fontWeight: FontWeight.w700,
1812
+ ),
1813
+ ),
1814
+ const SizedBox(height: 4),
1815
+ Text(
1816
+ provider.description,
1817
+ maxLines: 2,
1818
+ overflow: TextOverflow.ellipsis,
1819
+ style: TextStyle(color: _textSecondary, height: 1.4),
1820
+ ),
1821
+ ],
1822
+ ),
1823
+ ),
1824
+ const SizedBox(width: 8),
1825
+ Column(
1826
+ crossAxisAlignment: CrossAxisAlignment.end,
1827
+ children: <Widget>[
1828
+ _StatusPill(
1829
+ label: enabled ? provider.statusLabel : 'Disabled',
1830
+ color: enabled ? provider.statusColor : _textSecondary,
1831
+ ),
1832
+ const SizedBox(height: 8),
1833
+ InkWell(
1834
+ onTap: hasAdvancedFields || models.isNotEmpty
1835
+ ? onExpandToggle
1836
+ : null,
1837
+ borderRadius: BorderRadius.circular(999),
1838
+ child: Container(
1839
+ padding: const EdgeInsets.symmetric(
1840
+ horizontal: 10,
1841
+ vertical: 6,
1842
+ ),
1843
+ decoration: BoxDecoration(
1844
+ color: _bgCard,
1845
+ borderRadius: BorderRadius.circular(999),
1846
+ border: Border.all(color: _border),
1847
+ ),
1848
+ child: Row(
1849
+ mainAxisSize: MainAxisSize.min,
1850
+ children: <Widget>[
1851
+ Text(
1852
+ expanded ? 'Hide' : 'Setup',
1853
+ style: TextStyle(fontSize: 12),
1854
+ ),
1855
+ const SizedBox(width: 4),
1856
+ Icon(
1857
+ expanded
1858
+ ? Icons.keyboard_arrow_up
1859
+ : Icons.keyboard_arrow_down,
1860
+ size: 16,
1861
+ color: _textSecondary,
1862
+ ),
1863
+ ],
1864
+ ),
1865
+ ),
1866
+ ),
1867
+ ],
1868
+ ),
1869
+ ],
1870
+ ),
1871
+ const SizedBox(height: 12),
1872
+ Wrap(
1873
+ spacing: 8,
1874
+ runSpacing: 8,
1875
+ children: <Widget>[
1876
+ _MetaPill(
1877
+ label: '$availableCount of ${models.length} models ready',
1878
+ icon: Icons.memory_outlined,
1879
+ ),
1880
+ if (provider.supportsApiKey && provider.credentialConfigured)
1881
+ const _MetaPill(
1882
+ label: 'Credentials ready',
1883
+ icon: Icons.lock_outline,
1884
+ ),
1885
+ if (provider.supportsApiKey && !provider.credentialConfigured)
1886
+ const _MetaPill(
1887
+ label: 'Credentials needed',
1888
+ icon: Icons.admin_panel_settings_outlined,
1889
+ ),
1890
+ if (provider.supportsBaseUrl &&
1891
+ baseUrlController.text.trim().isNotEmpty)
1892
+ _MetaPill(
1893
+ label: _friendlyBaseUrlLabel(baseUrlController.text.trim()),
1894
+ icon: Icons.link_outlined,
1895
+ ),
1896
+ ],
1897
+ ),
1898
+ const SizedBox(height: 12),
1899
+ Container(
1900
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1901
+ decoration: BoxDecoration(
1902
+ color: _bgCard,
1903
+ borderRadius: BorderRadius.circular(14),
1904
+ border: Border.all(color: _border),
1905
+ ),
1906
+ child: Row(
1907
+ children: <Widget>[
1908
+ Expanded(
1909
+ child: Text(
1910
+ provider.availabilityReason,
1911
+ style: TextStyle(color: _textSecondary, height: 1.35),
1912
+ ),
1913
+ ),
1914
+ const SizedBox(width: 12),
1915
+ Switch(value: enabled, onChanged: onEnabledChanged),
1916
+ ],
1917
+ ),
1918
+ ),
1919
+ if (expanded) ...<Widget>[
1920
+ const SizedBox(height: 14),
1921
+ if (provider.supportsApiKey)
1922
+ Container(
1923
+ width: double.infinity,
1924
+ padding: const EdgeInsets.all(12),
1925
+ margin: const EdgeInsets.only(bottom: 12),
1926
+ decoration: BoxDecoration(
1927
+ color: _bgCard,
1928
+ borderRadius: BorderRadius.circular(14),
1929
+ border: Border.all(color: _border),
1930
+ ),
1931
+ child: Text(
1932
+ provider.credentialConfigured
1933
+ ? 'Credentials for this provider are already available to the runtime.'
1934
+ : 'Credentials for this provider are managed outside this workspace UI. Finish the server or admin setup, then return here to enable routing.',
1935
+ style: TextStyle(color: _textSecondary, height: 1.35),
1936
+ ),
1937
+ ),
1938
+ if (provider.supportsBaseUrl) ...<Widget>[
1939
+ TextField(
1940
+ controller: baseUrlController,
1941
+ keyboardType: TextInputType.url,
1942
+ autocorrect: false,
1943
+ decoration: InputDecoration(
1944
+ labelText: provider.id == 'ollama'
1945
+ ? 'Server URL'
1946
+ : 'Base URL',
1947
+ helperText: provider.defaultBaseUrl.trim().isEmpty
1948
+ ? 'Optional override.'
1949
+ : 'Default: ${provider.defaultBaseUrl}',
1950
+ ),
1951
+ ),
1952
+ const SizedBox(height: 12),
1953
+ ],
1954
+ if (models.isNotEmpty) ...<Widget>[
1955
+ Text('Models', style: TextStyle(fontWeight: FontWeight.w700)),
1956
+ const SizedBox(height: 8),
1957
+ Wrap(
1958
+ spacing: 8,
1959
+ runSpacing: 8,
1960
+ children: models
1961
+ .map(
1962
+ (model) => Container(
1963
+ padding: const EdgeInsets.symmetric(
1964
+ horizontal: 10,
1965
+ vertical: 8,
1966
+ ),
1967
+ decoration: BoxDecoration(
1968
+ color: model.available ? _bgCard : _bgPrimary,
1969
+ borderRadius: BorderRadius.circular(999),
1970
+ border: Border.all(
1971
+ color: model.available ? _border : _borderLight,
1972
+ ),
1973
+ ),
1974
+ child: Text(
1975
+ model.label,
1976
+ style: TextStyle(
1977
+ fontSize: 12,
1978
+ color: model.available
1979
+ ? _textPrimary
1980
+ : _textSecondary,
1981
+ ),
1982
+ ),
1983
+ ),
1984
+ )
1985
+ .toList(),
1986
+ ),
1987
+ ],
1988
+ ],
1989
+ ],
1990
+ ),
1991
+ );
1992
+ }
1993
+ }
1994
+
1995
+ class _RoutingSelectCard extends StatelessWidget {
1996
+ const _RoutingSelectCard({
1997
+ required this.label,
1998
+ required this.icon,
1999
+ required this.value,
2000
+ required this.items,
2001
+ required this.onChanged,
2002
+ });
2003
+
2004
+ final String label;
2005
+ final IconData icon;
2006
+ final String value;
2007
+ final List<DropdownMenuItem<String>> items;
2008
+ final ValueChanged<String?> onChanged;
2009
+
2010
+ @override
2011
+ Widget build(BuildContext context) {
2012
+ return Container(
2013
+ padding: const EdgeInsets.all(14),
2014
+ decoration: BoxDecoration(
2015
+ color: _bgSecondary,
2016
+ borderRadius: BorderRadius.circular(16),
2017
+ border: Border.all(color: _border),
2018
+ ),
2019
+ child: Column(
2020
+ crossAxisAlignment: CrossAxisAlignment.start,
2021
+ children: <Widget>[
2022
+ Row(
2023
+ children: <Widget>[
2024
+ Icon(icon, size: 16, color: _accentHover),
2025
+ const SizedBox(width: 8),
2026
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2027
+ ],
2028
+ ),
2029
+ const SizedBox(height: 10),
2030
+ DropdownButtonFormField<String>(
2031
+ initialValue: value,
2032
+ items: items,
2033
+ decoration: const InputDecoration(isDense: true),
2034
+ onChanged: onChanged,
2035
+ ),
2036
+ ],
2037
+ ),
2038
+ );
2039
+ }
2040
+ }