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,2024 @@
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
+ ],
346
+ ),
347
+ ),
348
+ );
349
+ }
350
+
351
+ Widget _buildWorkspaceSection(NeoAgentController controller) {
352
+ return Card(
353
+ child: Padding(
354
+ padding: const EdgeInsets.all(20),
355
+ child: Column(
356
+ crossAxisAlignment: CrossAxisAlignment.start,
357
+ children: <Widget>[
358
+ const _SectionTitle('Workspace'),
359
+ const SizedBox(height: 10),
360
+ Text(
361
+ 'Controls for how the app runs on this device and in the browser.',
362
+ style: TextStyle(color: _textSecondary, height: 1.45),
363
+ ),
364
+ const SizedBox(height: 16),
365
+ Text(
366
+ 'Browser Runtime',
367
+ style: TextStyle(
368
+ fontWeight: FontWeight.w700,
369
+ color: _textPrimary,
370
+ ),
371
+ ),
372
+ const SizedBox(height: 12),
373
+ _SettingToggle(
374
+ title: 'Run browser headless',
375
+ subtitle:
376
+ 'Keep browser automation off-screen when visible windows are not needed.',
377
+ value: _headlessBrowser,
378
+ onChanged: (value) => setState(() => _headlessBrowser = value),
379
+ ),
380
+ const SizedBox(height: 12),
381
+ DropdownButtonFormField<String>(
382
+ initialValue: _browserBackend,
383
+ decoration: const InputDecoration(
384
+ labelText: 'Browser backend',
385
+ helperText:
386
+ 'Cloud uses this deployment. Extension uses a paired Chrome browser.',
387
+ ),
388
+ items: const <DropdownMenuItem<String>>[
389
+ DropdownMenuItem<String>(
390
+ value: 'cloud',
391
+ child: Text('Cloud (local)'),
392
+ ),
393
+ DropdownMenuItem<String>(
394
+ value: 'extension',
395
+ child: Text('Chrome extension'),
396
+ ),
397
+ ],
398
+ onChanged: (value) {
399
+ if (value != null) {
400
+ setState(() => _browserBackend = value);
401
+ }
402
+ },
403
+ ),
404
+ const SizedBox(height: 10),
405
+ Text(
406
+ _browserBackend == 'extension'
407
+ ? (controller.browserExtensionConnected
408
+ ? 'Chrome extension connected.'
409
+ : 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
410
+ : controller.cloudBrowserBackend == 'vm'
411
+ ? "Cloud uses this deployment's isolated VM browser runtime."
412
+ : "Cloud uses this deployment's local host browser runtime.",
413
+ style: TextStyle(color: _textSecondary, height: 1.4),
414
+ ),
415
+ const SizedBox(height: 10),
416
+ Wrap(
417
+ spacing: 10,
418
+ runSpacing: 10,
419
+ children: <Widget>[
420
+ OutlinedButton.icon(
421
+ onPressed: controller.downloadBrowserExtension,
422
+ icon: Icon(Icons.download_outlined),
423
+ label: Text('Download extension'),
424
+ ),
425
+ OutlinedButton.icon(
426
+ onPressed: controller.refreshBrowserExtensionStatus,
427
+ icon: Icon(Icons.sync),
428
+ label: Text('Refresh status'),
429
+ ),
430
+ ],
431
+ ),
432
+ const Divider(height: 32),
433
+ Text(
434
+ 'Routing Behavior',
435
+ style: TextStyle(
436
+ fontWeight: FontWeight.w700,
437
+ color: _textPrimary,
438
+ ),
439
+ ),
440
+ const SizedBox(height: 12),
441
+ _SettingToggle(
442
+ title: 'Smart model selection',
443
+ subtitle:
444
+ 'Automatically choose the best enabled model for each task type.',
445
+ value: _smarterSelector,
446
+ onChanged: (value) => setState(() => _smarterSelector = value),
447
+ ),
448
+ ],
449
+ ),
450
+ ),
451
+ );
452
+ }
453
+
454
+ Widget _buildModelsSection({
455
+ required NeoAgentController controller,
456
+ required List<DropdownMenuItem<String>> modelChoices,
457
+ required List<ModelMeta> routingModels,
458
+ required List<ModelMeta> availableModels,
459
+ required int enabledSmartModels,
460
+ }) {
461
+ return Card(
462
+ child: Padding(
463
+ padding: const EdgeInsets.all(20),
464
+ child: Column(
465
+ crossAxisAlignment: CrossAxisAlignment.start,
466
+ children: <Widget>[
467
+ const _SectionTitle('Models'),
468
+ const SizedBox(height: 10),
469
+ Text(
470
+ 'Enable providers, then choose defaults for chat, agents, fallback behavior, and smart routing.',
471
+ style: TextStyle(color: _textSecondary, height: 1.45),
472
+ ),
473
+ const SizedBox(height: 16),
474
+ Text(
475
+ 'Providers',
476
+ style: TextStyle(
477
+ fontWeight: FontWeight.w700,
478
+ color: _textPrimary,
479
+ ),
480
+ ),
481
+ const SizedBox(height: 14),
482
+ if (controller.aiProviders.isEmpty)
483
+ Text(
484
+ 'Provider metadata is unavailable on this server version.',
485
+ style: TextStyle(color: _textSecondary),
486
+ )
487
+ else
488
+ LayoutBuilder(
489
+ builder: (context, constraints) {
490
+ final compact = constraints.maxWidth < 960;
491
+ final cardWidth = compact
492
+ ? constraints.maxWidth
493
+ : (constraints.maxWidth - 16) / 2;
494
+ return Wrap(
495
+ spacing: 16,
496
+ runSpacing: 16,
497
+ children: controller.aiProviders
498
+ .where(
499
+ (provider) =>
500
+ provider.available ||
501
+ _providerEnabled[provider.id] == true ||
502
+ controller
503
+ .aiProviderConfigs[provider.id]
504
+ ?.enabled ==
505
+ true,
506
+ )
507
+ .map((provider) {
508
+ return SizedBox(
509
+ width: cardWidth,
510
+ child: _AiProviderCard(
511
+ provider: provider,
512
+ enabled:
513
+ _providerEnabled[provider.id] ??
514
+ controller
515
+ .aiProviderConfigs[provider.id]
516
+ ?.enabled ??
517
+ true,
518
+ models: controller.supportedModels
519
+ .where(
520
+ (model) => model.provider == provider.id,
521
+ )
522
+ .toList(),
523
+ baseUrlController:
524
+ _providerBaseUrlControllers[provider.id]!,
525
+ expanded: _expandedProviderIds.contains(
526
+ provider.id,
527
+ ),
528
+ onEnabledChanged: (value) {
529
+ setState(() {
530
+ _providerEnabled[provider.id] = value;
531
+ });
532
+ },
533
+ onExpandToggle: () {
534
+ setState(() {
535
+ if (_expandedProviderIds.contains(
536
+ provider.id,
537
+ )) {
538
+ _expandedProviderIds.remove(provider.id);
539
+ } else {
540
+ _expandedProviderIds.add(provider.id);
541
+ }
542
+ });
543
+ },
544
+ ),
545
+ );
546
+ })
547
+ .toList(),
548
+ );
549
+ },
550
+ ),
551
+ const Divider(height: 32),
552
+ Text(
553
+ 'Default Routing',
554
+ style: TextStyle(
555
+ fontWeight: FontWeight.w700,
556
+ color: _textPrimary,
557
+ ),
558
+ ),
559
+ const SizedBox(height: 12),
560
+ if (routingModels.isNotEmpty)
561
+ LayoutBuilder(
562
+ builder: (context, constraints) {
563
+ final compact = constraints.maxWidth < 940;
564
+ final cardWidth = compact
565
+ ? constraints.maxWidth
566
+ : (constraints.maxWidth - 24) / 3;
567
+ return Wrap(
568
+ spacing: 12,
569
+ runSpacing: 12,
570
+ children: <Widget>[
571
+ SizedBox(
572
+ width: cardWidth,
573
+ child: _RoutingSelectCard(
574
+ label: 'Chat',
575
+ icon: Icons.chat_bubble_outline,
576
+ value: _ensureModelValue(
577
+ _defaultChatModel,
578
+ routingModels,
579
+ allowAuto: true,
580
+ ),
581
+ items: modelChoices,
582
+ onChanged: (value) {
583
+ if (value != null) {
584
+ setState(() => _defaultChatModel = value);
585
+ }
586
+ },
587
+ ),
588
+ ),
589
+ SizedBox(
590
+ width: cardWidth,
591
+ child: _RoutingSelectCard(
592
+ label: 'Sub-agent',
593
+ icon: Icons.bolt_outlined,
594
+ value: _ensureModelValue(
595
+ _defaultSubagentModel,
596
+ routingModels,
597
+ allowAuto: true,
598
+ ),
599
+ items: modelChoices,
600
+ onChanged: (value) {
601
+ if (value != null) {
602
+ setState(() => _defaultSubagentModel = value);
603
+ }
604
+ },
605
+ ),
606
+ ),
607
+ SizedBox(
608
+ width: cardWidth,
609
+ child: _RoutingSelectCard(
610
+ label: 'Fallback',
611
+ icon: Icons.shield_outlined,
612
+ value: _ensureModelValue(
613
+ _fallbackModel,
614
+ routingModels,
615
+ allowAuto: false,
616
+ ),
617
+ items: routingModels
618
+ .map(
619
+ (model) => DropdownMenuItem<String>(
620
+ value: model.id,
621
+ child: Text(model.label),
622
+ ),
623
+ )
624
+ .toList(),
625
+ onChanged: (value) {
626
+ if (value != null) {
627
+ setState(() => _fallbackModel = value);
628
+ }
629
+ },
630
+ ),
631
+ ),
632
+ ],
633
+ );
634
+ },
635
+ ),
636
+ const Divider(height: 32),
637
+ Text(
638
+ 'Smart Selector Pool',
639
+ style: TextStyle(
640
+ fontWeight: FontWeight.w700,
641
+ color: _textPrimary,
642
+ ),
643
+ ),
644
+ const SizedBox(height: 10),
645
+ Wrap(
646
+ spacing: 10,
647
+ runSpacing: 10,
648
+ children: controller.supportedModels.map((model) {
649
+ final selected = _enabledModels.contains(model.id);
650
+ return FilterChip(
651
+ label: Text(
652
+ model.available
653
+ ? model.label
654
+ : '${model.label} (${model.providerStatusLabel})',
655
+ ),
656
+ selected: selected,
657
+ selectedColor: _accentMuted,
658
+ checkmarkColor: _accent,
659
+ backgroundColor: _bgSecondary,
660
+ side: BorderSide(
661
+ color: model.available
662
+ ? _border
663
+ : _warning.withValues(alpha: 0.35),
664
+ ),
665
+ onSelected: model.available
666
+ ? (value) {
667
+ setState(() {
668
+ if (value) {
669
+ _enabledModels.add(model.id);
670
+ } else if (_enabledModels.length > 1) {
671
+ _enabledModels.remove(model.id);
672
+ }
673
+ });
674
+ }
675
+ : null,
676
+ );
677
+ }).toList(),
678
+ ),
679
+ const SizedBox(height: 14),
680
+ Text(
681
+ availableModels.isEmpty
682
+ ? 'Enable a ready provider above to unlock model routing.'
683
+ : '$enabledSmartModels models are currently eligible for smart routing.',
684
+ style: TextStyle(color: _textSecondary),
685
+ ),
686
+ ],
687
+ ),
688
+ ),
689
+ );
690
+ }
691
+
692
+ Widget _buildVoiceAndRecordingSection({
693
+ required NeoAgentController controller,
694
+ required List<DropdownMenuItem<String>> modelChoices,
695
+ required List<ModelMeta> routingModels,
696
+ }) {
697
+ final liveVoiceOptions =
698
+ _voiceLiveVoicesByProvider[_voiceLiveProvider] ?? const <String>[];
699
+ return Card(
700
+ child: Padding(
701
+ padding: const EdgeInsets.all(20),
702
+ child: Column(
703
+ crossAxisAlignment: CrossAxisAlignment.start,
704
+ children: <Widget>[
705
+ const _SectionTitle('Voice & Recording'),
706
+ const SizedBox(height: 10),
707
+ Text(
708
+ 'Defaults for transcription, summaries, and live voice.',
709
+ style: TextStyle(color: _textSecondary, height: 1.45),
710
+ ),
711
+ const SizedBox(height: 16),
712
+ Text(
713
+ 'Recording Defaults',
714
+ style: TextStyle(
715
+ fontWeight: FontWeight.w700,
716
+ color: _textPrimary,
717
+ ),
718
+ ),
719
+ const SizedBox(height: 12),
720
+ LayoutBuilder(
721
+ builder: (context, constraints) {
722
+ final compact = constraints.maxWidth < 940;
723
+ final cardWidth = compact
724
+ ? constraints.maxWidth
725
+ : (constraints.maxWidth - 12) / 2;
726
+ return Wrap(
727
+ spacing: 12,
728
+ runSpacing: 12,
729
+ children: <Widget>[
730
+ SizedBox(
731
+ width: cardWidth,
732
+ child: _RoutingSelectCard(
733
+ label: 'Recording Summary',
734
+ icon: Icons.summarize_outlined,
735
+ value: _ensureModelValue(
736
+ _defaultRecordingSummaryModel,
737
+ routingModels,
738
+ allowAuto: true,
739
+ ),
740
+ items: modelChoices,
741
+ onChanged: (value) {
742
+ if (value != null) {
743
+ setState(
744
+ () => _defaultRecordingSummaryModel = value,
745
+ );
746
+ }
747
+ },
748
+ ),
749
+ ),
750
+ SizedBox(
751
+ width: cardWidth,
752
+ child: _RoutingSelectCard(
753
+ label: 'Recording Transcription',
754
+ icon: Icons.hearing_outlined,
755
+ value: _defaultRecordingTranscriptionModel,
756
+ items: _recordingTranscriptionModelChoices(
757
+ _defaultRecordingTranscriptionModel,
758
+ ),
759
+ onChanged: (value) {
760
+ if (value != null) {
761
+ setState(() {
762
+ _defaultRecordingTranscriptionModel = value;
763
+ });
764
+ }
765
+ },
766
+ ),
767
+ ),
768
+ ],
769
+ );
770
+ },
771
+ ),
772
+ const Divider(height: 32),
773
+ Text(
774
+ 'Speech Processing',
775
+ style: TextStyle(
776
+ fontWeight: FontWeight.w700,
777
+ color: _textPrimary,
778
+ ),
779
+ ),
780
+ const SizedBox(height: 12),
781
+ LayoutBuilder(
782
+ builder: (context, constraints) {
783
+ final compact = constraints.maxWidth < 940;
784
+ final cardWidth = compact
785
+ ? constraints.maxWidth
786
+ : (constraints.maxWidth - 12) / 2;
787
+ return Wrap(
788
+ spacing: 12,
789
+ runSpacing: 12,
790
+ children: <Widget>[
791
+ SizedBox(
792
+ width: cardWidth,
793
+ child: _RoutingSelectCard(
794
+ label: 'Speech Model',
795
+ icon: Icons.record_voice_over_outlined,
796
+ value: _ensureModelValue(
797
+ _defaultSpeechModel,
798
+ routingModels,
799
+ allowAuto: true,
800
+ ),
801
+ items: modelChoices,
802
+ onChanged: (value) {
803
+ if (value != null) {
804
+ setState(() => _defaultSpeechModel = value);
805
+ }
806
+ },
807
+ ),
808
+ ),
809
+ ],
810
+ );
811
+ },
812
+ ),
813
+ const SizedBox(height: 10),
814
+ Text(
815
+ 'Used for the backend LLM that processes voice assistant and other speech-originated turns. This does not change the speech synthesis voice.',
816
+ style: TextStyle(color: _textSecondary, height: 1.4),
817
+ ),
818
+ const Divider(height: 32),
819
+ Text(
820
+ 'Live Voice',
821
+ style: TextStyle(
822
+ fontWeight: FontWeight.w700,
823
+ color: _textPrimary,
824
+ ),
825
+ ),
826
+ const SizedBox(height: 12),
827
+ LayoutBuilder(
828
+ builder: (context, constraints) {
829
+ final compact = constraints.maxWidth < 940;
830
+ final cardWidth = compact
831
+ ? constraints.maxWidth
832
+ : (constraints.maxWidth - 24) / 3;
833
+ return Wrap(
834
+ spacing: 12,
835
+ runSpacing: 12,
836
+ children: <Widget>[
837
+ SizedBox(
838
+ width: cardWidth,
839
+ child: _RoutingSelectCard(
840
+ label: 'Live Provider',
841
+ icon: Icons.call_outlined,
842
+ value: _voiceLiveProvider,
843
+ items: const <String>['openai', 'gemini']
844
+ .map(
845
+ (value) => DropdownMenuItem<String>(
846
+ value: value,
847
+ child: Text(value),
848
+ ),
849
+ )
850
+ .toList(),
851
+ onChanged: (value) {
852
+ if (value == null) return;
853
+ setState(() {
854
+ _voiceLiveProvider = value;
855
+ final modelOptions =
856
+ _voiceLiveModelsByProvider[_voiceLiveProvider] ??
857
+ const <String>[];
858
+ if (!modelOptions.contains(_voiceLiveModel) &&
859
+ modelOptions.isNotEmpty) {
860
+ _voiceLiveModel = modelOptions.first;
861
+ }
862
+ final voiceOptions =
863
+ _voiceLiveVoicesByProvider[_voiceLiveProvider] ??
864
+ const <String>[];
865
+ if (voiceOptions.isNotEmpty &&
866
+ !voiceOptions.contains(_voiceLiveVoice)) {
867
+ _voiceLiveVoice = voiceOptions.first;
868
+ }
869
+ });
870
+ },
871
+ ),
872
+ ),
873
+ SizedBox(
874
+ width: cardWidth,
875
+ child: _RoutingSelectCard(
876
+ label: 'Live Model',
877
+ icon: Icons.speed_outlined,
878
+ value: _voiceLiveModel,
879
+ items:
880
+ (_voiceLiveModelsByProvider[_voiceLiveProvider] ??
881
+ const <String>[])
882
+ .map(
883
+ (value) => DropdownMenuItem<String>(
884
+ value: value,
885
+ child: Text(value),
886
+ ),
887
+ )
888
+ .toList(),
889
+ onChanged: (value) {
890
+ if (value != null) {
891
+ setState(() => _voiceLiveModel = value);
892
+ }
893
+ },
894
+ ),
895
+ ),
896
+ if (liveVoiceOptions.isNotEmpty)
897
+ SizedBox(
898
+ width: cardWidth,
899
+ child: _RoutingSelectCard(
900
+ label: 'Live Voice',
901
+ icon: Icons.graphic_eq_outlined,
902
+ value: _voiceLiveVoice,
903
+ items: liveVoiceOptions
904
+ .map(
905
+ (value) => DropdownMenuItem<String>(
906
+ value: value,
907
+ child: Text(value),
908
+ ),
909
+ )
910
+ .toList(),
911
+ onChanged: (value) {
912
+ if (value != null) {
913
+ setState(() => _voiceLiveVoice = value);
914
+ }
915
+ },
916
+ ),
917
+ ),
918
+ ],
919
+ );
920
+ },
921
+ ),
922
+ ],
923
+ ),
924
+ ),
925
+ );
926
+ }
927
+
928
+ Widget _buildDesktopSection(NeoAgentController controller) {
929
+ return Card(
930
+ child: Padding(
931
+ padding: const EdgeInsets.all(20),
932
+ child: Column(
933
+ crossAxisAlignment: CrossAxisAlignment.start,
934
+ children: <Widget>[
935
+ const _SectionTitle('Desktop'),
936
+ const SizedBox(height: 10),
937
+ Text(
938
+ 'Desktop-only recording and companion controls for this computer.',
939
+ style: TextStyle(color: _textSecondary, height: 1.45),
940
+ ),
941
+ const SizedBox(height: 16),
942
+ Text(
943
+ 'Local App Behavior',
944
+ style: TextStyle(
945
+ fontWeight: FontWeight.w700,
946
+ color: _textPrimary,
947
+ ),
948
+ ),
949
+ const SizedBox(height: 12),
950
+ SwitchListTile.adaptive(
951
+ value: controller.desktopAskOnClose,
952
+ contentPadding: EdgeInsets.zero,
953
+ title: Text('Ask before closing to background'),
954
+ subtitle: Text(
955
+ 'Prompt for whether NeoAgent should stay resident in the tray when the main window closes.',
956
+ style: TextStyle(color: _textSecondary),
957
+ ),
958
+ onChanged: (value) => controller.setDesktopClosePreference(
959
+ askOnClose: value,
960
+ keepRunningOnClose: controller.desktopKeepRunningOnClose,
961
+ ),
962
+ ),
963
+ SwitchListTile.adaptive(
964
+ value: controller.desktopAutoShowFloatingToolbar,
965
+ contentPadding: EdgeInsets.zero,
966
+ title: Text('Auto-show floating toolbar'),
967
+ subtitle: Text(
968
+ 'Open the compact recording bar automatically whenever a desktop studio session starts.',
969
+ style: TextStyle(color: _textSecondary),
970
+ ),
971
+ onChanged: controller.setDesktopAutoShowFloatingToolbar,
972
+ ),
973
+ SwitchListTile.adaptive(
974
+ value: controller.desktopAssistantHotkeyEnabled,
975
+ contentPadding: EdgeInsets.zero,
976
+ title: Text('Reserve assistant hotkey'),
977
+ subtitle: Text(
978
+ 'Register $_desktopAssistantHotkeyLabel so the desktop shell is ready for the upcoming voice assistant summon flow.',
979
+ style: TextStyle(color: _textSecondary),
980
+ ),
981
+ onChanged: controller.recordingRuntime.supportsGlobalHotkeys
982
+ ? controller.setDesktopAssistantHotkeyEnabled
983
+ : null,
984
+ ),
985
+ const SizedBox(height: 12),
986
+ Wrap(
987
+ spacing: 10,
988
+ runSpacing: 10,
989
+ children: <Widget>[
990
+ _RecordingPermissionBadge(
991
+ label: 'Microphone',
992
+ state: controller.recordingRuntime.microphonePermission,
993
+ ),
994
+ _RecordingPermissionBadge(
995
+ label: 'System audio',
996
+ state: controller.recordingRuntime.systemAudioPermission,
997
+ ),
998
+ ],
999
+ ),
1000
+ const Divider(height: 32),
1001
+ Text(
1002
+ 'Companion Mode',
1003
+ style: TextStyle(
1004
+ fontWeight: FontWeight.w700,
1005
+ color: _textPrimary,
1006
+ ),
1007
+ ),
1008
+ const SizedBox(height: 12),
1009
+ SwitchListTile.adaptive(
1010
+ value: controller.desktopCompanionEnabled,
1011
+ contentPadding: EdgeInsets.zero,
1012
+ title: Text('Enable Companion Mode on this computer'),
1013
+ subtitle: Text(
1014
+ 'Expose this signed-in desktop app as a controllable companion device without a separate pairing flow.',
1015
+ style: TextStyle(color: _textSecondary),
1016
+ ),
1017
+ onChanged: controller.setDesktopCompanionEnabled,
1018
+ ),
1019
+ SwitchListTile.adaptive(
1020
+ value: controller.desktopCompanionPaused,
1021
+ contentPadding: EdgeInsets.zero,
1022
+ title: Text('Pause Companion Mode'),
1023
+ subtitle: Text(
1024
+ 'Keep the device registered but reject remote control commands locally until resumed.',
1025
+ style: TextStyle(color: _textSecondary),
1026
+ ),
1027
+ onChanged: controller.desktopCompanionEnabled
1028
+ ? controller.setDesktopCompanionPaused
1029
+ : null,
1030
+ ),
1031
+ const SizedBox(height: 12),
1032
+ TextFormField(
1033
+ initialValue: controller.desktopCompanionLabel,
1034
+ enabled: controller.desktopCompanionEnabled,
1035
+ decoration: const InputDecoration(
1036
+ labelText: 'Companion device label',
1037
+ hintText: 'My workstation',
1038
+ prefixIcon: Icon(Icons.edit_outlined),
1039
+ ),
1040
+ onFieldSubmitted: controller.setDesktopCompanionLabel,
1041
+ ),
1042
+ const SizedBox(height: 14),
1043
+ Wrap(
1044
+ spacing: 10,
1045
+ runSpacing: 10,
1046
+ children: <Widget>[
1047
+ _DotStatus(
1048
+ label: controller.desktopCompanionConnected
1049
+ ? 'Connected'
1050
+ : controller.desktopCompanionConnecting
1051
+ ? 'Connecting'
1052
+ : 'Disconnected',
1053
+ color: controller.desktopCompanionConnected
1054
+ ? _success
1055
+ : controller.desktopCompanionConnecting
1056
+ ? _accent
1057
+ : _warning,
1058
+ ),
1059
+ _DotStatus(
1060
+ label: controller.desktopCompanionPaused ? 'Paused' : 'Ready',
1061
+ color: controller.desktopCompanionPaused
1062
+ ? _warning
1063
+ : _success,
1064
+ ),
1065
+ ],
1066
+ ),
1067
+ if (controller.desktopCompanionErrorMessage
1068
+ case final message?) ...<Widget>[
1069
+ const SizedBox(height: 12),
1070
+ _InlineError(message: message),
1071
+ ],
1072
+ const SizedBox(height: 14),
1073
+ Builder(
1074
+ builder: (context) {
1075
+ final status = controller.desktopCompanionStatus;
1076
+ final permissionsRaw = status['permissions'];
1077
+ final permissions = permissionsRaw is Map
1078
+ ? permissionsRaw.map(
1079
+ (key, value) => MapEntry(
1080
+ key.toString(),
1081
+ value?.toString() ?? 'unknown',
1082
+ ),
1083
+ )
1084
+ : const <String, String>{};
1085
+ final screenCaptureState =
1086
+ permissions['screenCapture'] ?? 'unknown';
1087
+ final inputControlState =
1088
+ permissions['inputControl'] ?? 'unknown';
1089
+ final accessibilityState =
1090
+ permissions['accessibility'] ?? 'unknown';
1091
+ final grantHelp = switch (defaultTargetPlatform) {
1092
+ TargetPlatform.macOS =>
1093
+ 'Grant Screen Recording and Accessibility in System Settings, then press Re-check.',
1094
+ TargetPlatform.windows =>
1095
+ 'Grant capture and accessibility/input permissions in Windows Settings, then press Re-check.',
1096
+ TargetPlatform.linux =>
1097
+ 'Approve portal capture/input prompts and desktop accessibility access, then press Re-check.',
1098
+ TargetPlatform.android ||
1099
+ TargetPlatform.iOS ||
1100
+ TargetPlatform.fuchsia =>
1101
+ 'Desktop companion permission controls are unavailable on this platform.',
1102
+ };
1103
+ return Column(
1104
+ crossAxisAlignment: CrossAxisAlignment.start,
1105
+ children: <Widget>[
1106
+ Text(
1107
+ 'Permissions',
1108
+ style: TextStyle(
1109
+ fontWeight: FontWeight.w700,
1110
+ color: _textPrimary,
1111
+ ),
1112
+ ),
1113
+ const SizedBox(height: 8),
1114
+ Text(
1115
+ grantHelp,
1116
+ style: TextStyle(color: _textSecondary, height: 1.4),
1117
+ ),
1118
+ const SizedBox(height: 10),
1119
+ Wrap(
1120
+ spacing: 10,
1121
+ runSpacing: 10,
1122
+ children: <Widget>[
1123
+ _CompanionPermissionBadge(
1124
+ label: 'Screen capture',
1125
+ state: screenCaptureState,
1126
+ ),
1127
+ _CompanionPermissionBadge(
1128
+ label: 'Input control',
1129
+ state: inputControlState,
1130
+ ),
1131
+ _CompanionPermissionBadge(
1132
+ label: 'Accessibility',
1133
+ state: accessibilityState,
1134
+ ),
1135
+ ],
1136
+ ),
1137
+ const SizedBox(height: 12),
1138
+ Wrap(
1139
+ spacing: 10,
1140
+ runSpacing: 10,
1141
+ children: <Widget>[
1142
+ OutlinedButton.icon(
1143
+ onPressed: controller.desktopCompanionEnabled
1144
+ ? controller.refreshDesktopCompanionStatus
1145
+ : null,
1146
+ icon: Icon(Icons.sync_outlined),
1147
+ label: Text('Re-check permissions'),
1148
+ ),
1149
+ OutlinedButton.icon(
1150
+ onPressed: controller.desktopCompanionEnabled
1151
+ ? () => controller
1152
+ .openDesktopCompanionPermissionSettings(
1153
+ 'screenCapture',
1154
+ )
1155
+ : null,
1156
+ icon: Icon(Icons.monitor_outlined),
1157
+ label: Text('Open capture settings'),
1158
+ ),
1159
+ OutlinedButton.icon(
1160
+ onPressed: controller.desktopCompanionEnabled
1161
+ ? () => controller
1162
+ .openDesktopCompanionPermissionSettings(
1163
+ 'accessibility',
1164
+ )
1165
+ : null,
1166
+ icon: Icon(Icons.keyboard_command_key_outlined),
1167
+ label: Text('Open input/access settings'),
1168
+ ),
1169
+ OutlinedButton.icon(
1170
+ onPressed: controller.desktopCompanionEnabled
1171
+ ? controller.rotateDesktopCompanionIdentity
1172
+ : null,
1173
+ icon: Icon(Icons.refresh_outlined),
1174
+ label: Text('Reset Device Identity'),
1175
+ ),
1176
+ ],
1177
+ ),
1178
+ ],
1179
+ );
1180
+ },
1181
+ ),
1182
+ ],
1183
+ ),
1184
+ ),
1185
+ );
1186
+ }
1187
+
1188
+ Widget _buildUpdatesSection(NeoAgentController controller) {
1189
+ return Card(
1190
+ child: Padding(
1191
+ padding: const EdgeInsets.all(20),
1192
+ child: Column(
1193
+ crossAxisAlignment: CrossAxisAlignment.start,
1194
+ children: <Widget>[
1195
+ const _SectionTitle('Updates'),
1196
+ const SizedBox(height: 10),
1197
+ Text(
1198
+ 'Client and runtime update controls live here.',
1199
+ style: TextStyle(color: _textSecondary, height: 1.45),
1200
+ ),
1201
+ const SizedBox(height: 16),
1202
+ LayoutBuilder(
1203
+ builder: (context, constraints) {
1204
+ final compact = constraints.maxWidth < 720;
1205
+ final checkButton = FilledButton.icon(
1206
+ onPressed:
1207
+ controller.isCheckingAppUpdate ||
1208
+ !controller.appUpdaterConfigured
1209
+ ? null
1210
+ : () => controller.checkForAppUpdates(),
1211
+ style: FilledButton.styleFrom(backgroundColor: _accent),
1212
+ icon: controller.isCheckingAppUpdate
1213
+ ? const SizedBox.square(
1214
+ dimension: 16,
1215
+ child: CircularProgressIndicator(
1216
+ strokeWidth: 2,
1217
+ color: Colors.white,
1218
+ ),
1219
+ )
1220
+ : const Icon(Icons.sync),
1221
+ label: Text(
1222
+ controller.isCheckingAppUpdate
1223
+ ? 'Checking...'
1224
+ : 'Check now',
1225
+ ),
1226
+ );
1227
+ final appHeading = Text(
1228
+ 'Client App',
1229
+ style: TextStyle(
1230
+ fontWeight: FontWeight.w700,
1231
+ color: _textPrimary,
1232
+ ),
1233
+ );
1234
+ if (compact) {
1235
+ return Column(
1236
+ crossAxisAlignment: CrossAxisAlignment.start,
1237
+ children: <Widget>[
1238
+ appHeading,
1239
+ const SizedBox(height: 10),
1240
+ checkButton,
1241
+ ],
1242
+ );
1243
+ }
1244
+ return Row(
1245
+ children: <Widget>[
1246
+ Expanded(child: appHeading),
1247
+ checkButton,
1248
+ ],
1249
+ );
1250
+ },
1251
+ ),
1252
+ const SizedBox(height: 12),
1253
+ if (!controller.appUpdaterConfigured)
1254
+ Text(
1255
+ kIsWeb
1256
+ ? 'Client app update checks are disabled in the web app to avoid blocked browser-side GitHub requests.'
1257
+ : 'Client app updates are not configured for this build.',
1258
+ style: TextStyle(color: _textSecondary, height: 1.5),
1259
+ )
1260
+ else ...<Widget>[
1261
+ LayoutBuilder(
1262
+ builder: (context, constraints) {
1263
+ final compact = constraints.maxWidth < 780;
1264
+ final channelPicker = DropdownButtonFormField<String>(
1265
+ initialValue: controller.appUpdateChannel,
1266
+ decoration: const InputDecoration(
1267
+ labelText: 'App release channel',
1268
+ ),
1269
+ items: const <DropdownMenuItem<String>>[
1270
+ DropdownMenuItem<String>(
1271
+ value: 'stable',
1272
+ child: Text('Stable'),
1273
+ ),
1274
+ DropdownMenuItem<String>(
1275
+ value: 'beta',
1276
+ child: Text('Beta'),
1277
+ ),
1278
+ ],
1279
+ onChanged: (value) {
1280
+ if (value != null) {
1281
+ unawaited(controller.setAppUpdateChannel(value));
1282
+ }
1283
+ },
1284
+ );
1285
+ final autoCheck = SwitchListTile.adaptive(
1286
+ value: controller.appUpdateAutoCheckEnabled,
1287
+ contentPadding: EdgeInsets.zero,
1288
+ title: Text('Check automatically on launch'),
1289
+ subtitle: Text(
1290
+ 'This only checks GitHub Releases on startup. Installation still requires your confirmation.',
1291
+ style: TextStyle(color: _textSecondary),
1292
+ ),
1293
+ onChanged: controller.setAppUpdateAutoCheckEnabled,
1294
+ );
1295
+
1296
+ if (compact) {
1297
+ return Column(
1298
+ crossAxisAlignment: CrossAxisAlignment.start,
1299
+ children: <Widget>[
1300
+ channelPicker,
1301
+ const SizedBox(height: 10),
1302
+ autoCheck,
1303
+ ],
1304
+ );
1305
+ }
1306
+
1307
+ return Row(
1308
+ crossAxisAlignment: CrossAxisAlignment.start,
1309
+ children: <Widget>[
1310
+ Expanded(child: channelPicker),
1311
+ const SizedBox(width: 16),
1312
+ Expanded(child: autoCheck),
1313
+ ],
1314
+ );
1315
+ },
1316
+ ),
1317
+ const SizedBox(height: 8),
1318
+ Text(
1319
+ 'Installed: ${controller.installedAppVersion ?? 'Unknown'} | Channel: ${controller.appUpdateChannelLabel} | Last checked: ${controller.appUpdateLastCheckedLabel}',
1320
+ style: TextStyle(color: _textSecondary),
1321
+ ),
1322
+ const SizedBox(height: 6),
1323
+ Text(
1324
+ 'Source: ${app_release_updater.appUpdaterGithubOwner}/${app_release_updater.appUpdaterGithubRepo}${app_release_updater.appUpdaterGithubToken.trim().isNotEmpty ? ' (override active)' : ''}',
1325
+ style: TextStyle(color: _textSecondary),
1326
+ ),
1327
+ if (controller.appUpdateErrorMessage
1328
+ case final message?) ...<Widget>[
1329
+ const SizedBox(height: 12),
1330
+ _InlineError(message: message),
1331
+ ],
1332
+ if (controller.availableAppUpdate
1333
+ case final release?) ...<Widget>[
1334
+ const SizedBox(height: 16),
1335
+ Container(
1336
+ width: double.infinity,
1337
+ padding: const EdgeInsets.all(16),
1338
+ decoration: BoxDecoration(
1339
+ color: _bgSecondary,
1340
+ borderRadius: BorderRadius.circular(18),
1341
+ border: Border.all(color: _border),
1342
+ ),
1343
+ child: Column(
1344
+ crossAxisAlignment: CrossAxisAlignment.start,
1345
+ children: <Widget>[
1346
+ Wrap(
1347
+ spacing: 10,
1348
+ runSpacing: 10,
1349
+ children: <Widget>[
1350
+ _StatusPill(
1351
+ label: 'Update ${release.version}',
1352
+ color: release.channel == 'beta'
1353
+ ? _warning
1354
+ : _accent,
1355
+ ),
1356
+ _StatusPill(
1357
+ label: release.asset.name,
1358
+ color: _textSecondary,
1359
+ ),
1360
+ ],
1361
+ ),
1362
+ const SizedBox(height: 10),
1363
+ Text(
1364
+ '${release.title} · ${release.publishedLabel} · ${release.asset.sizeLabel}',
1365
+ style: TextStyle(color: _textSecondary),
1366
+ ),
1367
+ if (release.body.trim().isNotEmpty) ...<Widget>[
1368
+ const SizedBox(height: 14),
1369
+ ConstrainedBox(
1370
+ constraints: const BoxConstraints(maxHeight: 220),
1371
+ child: SingleChildScrollView(
1372
+ child: MarkdownBody(
1373
+ data: release.body,
1374
+ selectable: true,
1375
+ styleSheet: MarkdownStyleSheet(
1376
+ p: TextStyle(
1377
+ color: _textSecondary,
1378
+ height: 1.45,
1379
+ ),
1380
+ ),
1381
+ ),
1382
+ ),
1383
+ ),
1384
+ ],
1385
+ const SizedBox(height: 14),
1386
+ Wrap(
1387
+ spacing: 10,
1388
+ runSpacing: 10,
1389
+ children: <Widget>[
1390
+ FilledButton.icon(
1391
+ onPressed: controller.isOpeningAppUpdate
1392
+ ? null
1393
+ : controller.openAppUpdate,
1394
+ style: FilledButton.styleFrom(
1395
+ backgroundColor: _accent,
1396
+ ),
1397
+ icon: controller.isOpeningAppUpdate
1398
+ ? const SizedBox.square(
1399
+ dimension: 16,
1400
+ child: CircularProgressIndicator(
1401
+ strokeWidth: 2,
1402
+ color: Colors.white,
1403
+ ),
1404
+ )
1405
+ : const Icon(Icons.system_update_alt),
1406
+ label: Text(
1407
+ controller.isOpeningAppUpdate
1408
+ ? 'Opening...'
1409
+ : 'Download update',
1410
+ ),
1411
+ ),
1412
+ if (release.htmlUrl.trim().isNotEmpty)
1413
+ OutlinedButton.icon(
1414
+ onPressed: () {
1415
+ unawaited(
1416
+ widget.controller._oauthLauncher.openExternal(
1417
+ url: release.htmlUrl,
1418
+ label: 'release_notes',
1419
+ ),
1420
+ );
1421
+ },
1422
+ icon: const Icon(Icons.open_in_new),
1423
+ label: Text('View release'),
1424
+ ),
1425
+ ],
1426
+ ),
1427
+ ],
1428
+ ),
1429
+ ),
1430
+ ] else ...<Widget>[
1431
+ const SizedBox(height: 12),
1432
+ Text(
1433
+ controller.isCheckingAppUpdate
1434
+ ? 'Checking GitHub releases...'
1435
+ : controller.appUpdateLastCheckedAt == null
1436
+ ? 'Choose a channel, then check GitHub releases.'
1437
+ : 'No newer app release is available on the selected channel.',
1438
+ style: TextStyle(color: _textSecondary, height: 1.45),
1439
+ ),
1440
+ ],
1441
+ ],
1442
+ const Divider(height: 32),
1443
+ if (controller.updateStatus.allowSelfUpdate) ...<Widget>[
1444
+ LayoutBuilder(
1445
+ builder: (context, constraints) {
1446
+ final compact = constraints.maxWidth < 780;
1447
+ final channelPicker = DropdownButtonFormField<String>(
1448
+ initialValue: controller.updateStatus.releaseChannel,
1449
+ decoration: const InputDecoration(
1450
+ labelText: 'Runtime release channel',
1451
+ ),
1452
+ items: const <DropdownMenuItem<String>>[
1453
+ DropdownMenuItem<String>(
1454
+ value: 'stable',
1455
+ child: Text('Stable'),
1456
+ ),
1457
+ DropdownMenuItem<String>(
1458
+ value: 'beta',
1459
+ child: Text('Beta'),
1460
+ ),
1461
+ ],
1462
+ onChanged:
1463
+ controller.isSavingReleaseChannel ||
1464
+ controller.isTriggeringUpdate ||
1465
+ controller.updateStatus.state == 'running'
1466
+ ? null
1467
+ : (value) {
1468
+ if (value != null) {
1469
+ unawaited(controller.setReleaseChannel(value));
1470
+ }
1471
+ },
1472
+ );
1473
+
1474
+ final channelHelper = Text(
1475
+ controller.updateStatus.releaseChannel == 'beta'
1476
+ ? 'Beta follows preview releases.'
1477
+ : 'Stable follows production releases.',
1478
+ style: TextStyle(color: _textSecondary),
1479
+ );
1480
+
1481
+ if (compact) {
1482
+ return Column(
1483
+ crossAxisAlignment: CrossAxisAlignment.start,
1484
+ children: <Widget>[
1485
+ channelPicker,
1486
+ const SizedBox(height: 8),
1487
+ channelHelper,
1488
+ const SizedBox(height: 16),
1489
+ ],
1490
+ );
1491
+ }
1492
+
1493
+ return Padding(
1494
+ padding: const EdgeInsets.only(bottom: 16),
1495
+ child: Row(
1496
+ crossAxisAlignment: CrossAxisAlignment.start,
1497
+ children: <Widget>[
1498
+ Expanded(child: channelPicker),
1499
+ const SizedBox(width: 12),
1500
+ Expanded(child: channelHelper),
1501
+ ],
1502
+ ),
1503
+ );
1504
+ },
1505
+ ),
1506
+ LayoutBuilder(
1507
+ builder: (context, constraints) {
1508
+ final compact = constraints.maxWidth < 780;
1509
+ final runtimeTitle = Text(
1510
+ 'Runtime',
1511
+ style: TextStyle(
1512
+ fontWeight: FontWeight.w700,
1513
+ color: _textPrimary,
1514
+ ),
1515
+ );
1516
+ final updateButton = FilledButton.icon(
1517
+ onPressed:
1518
+ controller.isSavingReleaseChannel ||
1519
+ controller.isTriggeringUpdate ||
1520
+ controller.updateStatus.state == 'running'
1521
+ ? null
1522
+ : controller.triggerUpdate,
1523
+ style: FilledButton.styleFrom(backgroundColor: _accent),
1524
+ icon: controller.isTriggeringUpdate
1525
+ ? const SizedBox.square(
1526
+ dimension: 16,
1527
+ child: CircularProgressIndicator(
1528
+ strokeWidth: 2,
1529
+ color: Colors.white,
1530
+ ),
1531
+ )
1532
+ : Icon(Icons.system_update),
1533
+ label: Text('Update'),
1534
+ );
1535
+ if (compact) {
1536
+ return Column(
1537
+ crossAxisAlignment: CrossAxisAlignment.start,
1538
+ children: <Widget>[
1539
+ runtimeTitle,
1540
+ const SizedBox(height: 10),
1541
+ updateButton,
1542
+ ],
1543
+ );
1544
+ }
1545
+ return Row(
1546
+ children: <Widget>[
1547
+ Expanded(child: runtimeTitle),
1548
+ updateButton,
1549
+ ],
1550
+ );
1551
+ },
1552
+ ),
1553
+ ] else ...<Widget>[
1554
+ Text(
1555
+ 'Runtime',
1556
+ style: TextStyle(
1557
+ fontWeight: FontWeight.w700,
1558
+ color: _textPrimary,
1559
+ ),
1560
+ ),
1561
+ const SizedBox(height: 10),
1562
+ Text(
1563
+ 'Updates and release tracks are managed for this deployment.',
1564
+ style: TextStyle(color: _textSecondary),
1565
+ ),
1566
+ ],
1567
+ const SizedBox(height: 12),
1568
+ LayoutBuilder(
1569
+ builder: (context, constraints) {
1570
+ final compact = constraints.maxWidth < 760;
1571
+ final statusRow = Wrap(
1572
+ spacing: 10,
1573
+ runSpacing: 10,
1574
+ crossAxisAlignment: WrapCrossAlignment.center,
1575
+ children: <Widget>[
1576
+ _StatusPill(
1577
+ label: controller.updateStatus.badgeLabel,
1578
+ color: controller.updateStatus.badgeColor,
1579
+ ),
1580
+ _StatusPill(
1581
+ label: controller.updateStatus.releaseChannelLabel,
1582
+ color: controller.updateStatus.releaseChannel == 'beta'
1583
+ ? _warning
1584
+ : _accent,
1585
+ ),
1586
+ Text(
1587
+ controller.updateStatus.message,
1588
+ style: TextStyle(color: _textSecondary),
1589
+ ),
1590
+ Text('${controller.updateStatus.progress}%'),
1591
+ ],
1592
+ );
1593
+ if (compact) {
1594
+ return Column(
1595
+ crossAxisAlignment: CrossAxisAlignment.start,
1596
+ children: <Widget>[statusRow],
1597
+ );
1598
+ }
1599
+ return statusRow;
1600
+ },
1601
+ ),
1602
+ const SizedBox(height: 10),
1603
+ ClipRRect(
1604
+ borderRadius: BorderRadius.circular(999),
1605
+ child: LinearProgressIndicator(
1606
+ minHeight: 8,
1607
+ value: controller.updateStatus.progress / 100,
1608
+ backgroundColor: _bgSecondary,
1609
+ color: _accent,
1610
+ ),
1611
+ ),
1612
+ const SizedBox(height: 12),
1613
+ Text(controller.updateStatus.versionLine),
1614
+ ],
1615
+ ),
1616
+ ),
1617
+ );
1618
+ }
1619
+
1620
+ Widget _buildDiagnosticsSection(NeoAgentController controller) {
1621
+ return Card(
1622
+ child: Padding(
1623
+ padding: const EdgeInsets.all(20),
1624
+ child: Column(
1625
+ crossAxisAlignment: CrossAxisAlignment.start,
1626
+ children: <Widget>[
1627
+ Row(
1628
+ children: <Widget>[
1629
+ const _SectionTitle('Diagnostics'),
1630
+ const SizedBox(width: 8),
1631
+ Icon(Icons.info_outline, size: 16, color: _textSecondary),
1632
+ ],
1633
+ ),
1634
+ const SizedBox(height: 10),
1635
+ Text(
1636
+ 'Usage and health signals that help explain current runtime behavior without digging through logs first.',
1637
+ style: TextStyle(color: _textSecondary, height: 1.45),
1638
+ ),
1639
+ const SizedBox(height: 14),
1640
+ if (controller.tokenUsage == null)
1641
+ Text(
1642
+ 'Token usage unavailable on this server version.',
1643
+ style: TextStyle(color: _textSecondary),
1644
+ )
1645
+ else
1646
+ Column(
1647
+ crossAxisAlignment: CrossAxisAlignment.start,
1648
+ children: <Widget>[
1649
+ Text(
1650
+ 'Total: ${controller.tokenUsage!.totalTokensLabel} tokens across ${controller.tokenUsage!.totalRunsLabel} runs',
1651
+ ),
1652
+ const SizedBox(height: 6),
1653
+ Text(
1654
+ 'Last 7 days: ${controller.tokenUsage!.last7DaysTokensLabel} tokens in ${controller.tokenUsage!.last7DaysRunsLabel} runs',
1655
+ ),
1656
+ const SizedBox(height: 6),
1657
+ Text(
1658
+ 'Avg/run: ${controller.tokenUsage!.avgTokensPerRunLabel} tokens',
1659
+ ),
1660
+ ],
1661
+ ),
1662
+ ],
1663
+ ),
1664
+ ),
1665
+ );
1666
+ }
1667
+
1668
+ String _providerForSelectedModel(String modelId, List<ModelMeta> models) {
1669
+ if (modelId.trim().isEmpty || modelId == 'auto') {
1670
+ return 'auto';
1671
+ }
1672
+ for (final model in models) {
1673
+ if (model.id == modelId) {
1674
+ return model.provider.trim().isEmpty ? 'auto' : model.provider;
1675
+ }
1676
+ }
1677
+ return 'auto';
1678
+ }
1679
+
1680
+ Map<String, dynamic> _buildProviderPayload() {
1681
+ final providerIds = <String>{
1682
+ ...widget.controller.aiProviders.map((provider) => provider.id),
1683
+ ...widget.controller.aiProviderConfigs.keys,
1684
+ };
1685
+
1686
+ return <String, dynamic>{
1687
+ for (final providerId in providerIds)
1688
+ providerId: <String, dynamic>{
1689
+ 'enabled':
1690
+ _providerEnabled[providerId] ??
1691
+ widget.controller.aiProviderConfigs[providerId]?.enabled ??
1692
+ true,
1693
+ 'baseUrl': _providerBaseUrlControllers[providerId]?.text.trim() ?? '',
1694
+ },
1695
+ };
1696
+ }
1697
+
1698
+ void _syncTextController(
1699
+ Map<String, TextEditingController> controllers,
1700
+ String id,
1701
+ String value,
1702
+ ) {
1703
+ final controller = controllers.putIfAbsent(
1704
+ id,
1705
+ () => TextEditingController(text: value),
1706
+ );
1707
+ if (controller.text != value) {
1708
+ controller.text = value;
1709
+ }
1710
+ }
1711
+
1712
+ void _pruneControllers(
1713
+ Map<String, TextEditingController> controllers,
1714
+ Set<String> activeIds,
1715
+ ) {
1716
+ final staleIds = controllers.keys
1717
+ .where((id) => !activeIds.contains(id))
1718
+ .toList();
1719
+ for (final id in staleIds) {
1720
+ controllers.remove(id)?.dispose();
1721
+ }
1722
+ }
1723
+
1724
+ List<DropdownMenuItem<String>> _recordingTranscriptionModelChoices(
1725
+ String current,
1726
+ ) {
1727
+ const defaults = <String>['nova-3', 'nova-2-general'];
1728
+ final normalizedCurrent = current.trim();
1729
+ final values = <String>{...defaults};
1730
+ if (normalizedCurrent.isNotEmpty) {
1731
+ values.add(normalizedCurrent);
1732
+ }
1733
+ return values
1734
+ .map(
1735
+ (value) => DropdownMenuItem<String>(value: value, child: Text(value)),
1736
+ )
1737
+ .toList();
1738
+ }
1739
+ }
1740
+
1741
+ class _AiProviderCard extends StatelessWidget {
1742
+ const _AiProviderCard({
1743
+ required this.provider,
1744
+ required this.enabled,
1745
+ required this.expanded,
1746
+ required this.models,
1747
+ required this.baseUrlController,
1748
+ required this.onEnabledChanged,
1749
+ required this.onExpandToggle,
1750
+ });
1751
+
1752
+ final AiProviderMeta provider;
1753
+ final bool enabled;
1754
+ final bool expanded;
1755
+ final List<ModelMeta> models;
1756
+ final TextEditingController baseUrlController;
1757
+ final ValueChanged<bool> onEnabledChanged;
1758
+ final VoidCallback onExpandToggle;
1759
+
1760
+ @override
1761
+ Widget build(BuildContext context) {
1762
+ final availableCount = models.where((model) => model.available).length;
1763
+ final hasAdvancedFields = provider.supportsBaseUrl || models.isNotEmpty;
1764
+ return Container(
1765
+ padding: const EdgeInsets.all(16),
1766
+ decoration: BoxDecoration(
1767
+ color: _bgSecondary,
1768
+ borderRadius: BorderRadius.circular(18),
1769
+ border: Border.all(color: _borderLight),
1770
+ ),
1771
+ child: Column(
1772
+ crossAxisAlignment: CrossAxisAlignment.start,
1773
+ children: <Widget>[
1774
+ Row(
1775
+ crossAxisAlignment: CrossAxisAlignment.start,
1776
+ children: <Widget>[
1777
+ Container(
1778
+ width: 44,
1779
+ height: 44,
1780
+ decoration: BoxDecoration(
1781
+ color: _accentMuted,
1782
+ borderRadius: BorderRadius.circular(14),
1783
+ ),
1784
+ child: Icon(provider.icon, color: _accentHover),
1785
+ ),
1786
+ const SizedBox(width: 12),
1787
+ Expanded(
1788
+ child: Column(
1789
+ crossAxisAlignment: CrossAxisAlignment.start,
1790
+ children: <Widget>[
1791
+ Text(
1792
+ provider.label,
1793
+ style: TextStyle(
1794
+ fontSize: 16,
1795
+ fontWeight: FontWeight.w700,
1796
+ ),
1797
+ ),
1798
+ const SizedBox(height: 4),
1799
+ Text(
1800
+ provider.description,
1801
+ maxLines: 2,
1802
+ overflow: TextOverflow.ellipsis,
1803
+ style: TextStyle(color: _textSecondary, height: 1.4),
1804
+ ),
1805
+ ],
1806
+ ),
1807
+ ),
1808
+ const SizedBox(width: 8),
1809
+ Column(
1810
+ crossAxisAlignment: CrossAxisAlignment.end,
1811
+ children: <Widget>[
1812
+ _StatusPill(
1813
+ label: enabled ? provider.statusLabel : 'Disabled',
1814
+ color: enabled ? provider.statusColor : _textSecondary,
1815
+ ),
1816
+ const SizedBox(height: 8),
1817
+ InkWell(
1818
+ onTap: hasAdvancedFields || models.isNotEmpty
1819
+ ? onExpandToggle
1820
+ : null,
1821
+ borderRadius: BorderRadius.circular(999),
1822
+ child: Container(
1823
+ padding: const EdgeInsets.symmetric(
1824
+ horizontal: 10,
1825
+ vertical: 6,
1826
+ ),
1827
+ decoration: BoxDecoration(
1828
+ color: _bgCard,
1829
+ borderRadius: BorderRadius.circular(999),
1830
+ border: Border.all(color: _border),
1831
+ ),
1832
+ child: Row(
1833
+ mainAxisSize: MainAxisSize.min,
1834
+ children: <Widget>[
1835
+ Text(
1836
+ expanded ? 'Hide' : 'Setup',
1837
+ style: TextStyle(fontSize: 12),
1838
+ ),
1839
+ const SizedBox(width: 4),
1840
+ Icon(
1841
+ expanded
1842
+ ? Icons.keyboard_arrow_up
1843
+ : Icons.keyboard_arrow_down,
1844
+ size: 16,
1845
+ color: _textSecondary,
1846
+ ),
1847
+ ],
1848
+ ),
1849
+ ),
1850
+ ),
1851
+ ],
1852
+ ),
1853
+ ],
1854
+ ),
1855
+ const SizedBox(height: 12),
1856
+ Wrap(
1857
+ spacing: 8,
1858
+ runSpacing: 8,
1859
+ children: <Widget>[
1860
+ _MetaPill(
1861
+ label: '$availableCount of ${models.length} models ready',
1862
+ icon: Icons.memory_outlined,
1863
+ ),
1864
+ if (provider.supportsApiKey && provider.credentialConfigured)
1865
+ const _MetaPill(
1866
+ label: 'Credentials ready',
1867
+ icon: Icons.lock_outline,
1868
+ ),
1869
+ if (provider.supportsApiKey && !provider.credentialConfigured)
1870
+ const _MetaPill(
1871
+ label: 'Credentials needed',
1872
+ icon: Icons.admin_panel_settings_outlined,
1873
+ ),
1874
+ if (provider.supportsBaseUrl &&
1875
+ baseUrlController.text.trim().isNotEmpty)
1876
+ _MetaPill(
1877
+ label: _friendlyBaseUrlLabel(baseUrlController.text.trim()),
1878
+ icon: Icons.link_outlined,
1879
+ ),
1880
+ ],
1881
+ ),
1882
+ const SizedBox(height: 12),
1883
+ Container(
1884
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1885
+ decoration: BoxDecoration(
1886
+ color: _bgCard,
1887
+ borderRadius: BorderRadius.circular(14),
1888
+ border: Border.all(color: _border),
1889
+ ),
1890
+ child: Row(
1891
+ children: <Widget>[
1892
+ Expanded(
1893
+ child: Text(
1894
+ provider.availabilityReason,
1895
+ style: TextStyle(color: _textSecondary, height: 1.35),
1896
+ ),
1897
+ ),
1898
+ const SizedBox(width: 12),
1899
+ Switch(value: enabled, onChanged: onEnabledChanged),
1900
+ ],
1901
+ ),
1902
+ ),
1903
+ if (expanded) ...<Widget>[
1904
+ const SizedBox(height: 14),
1905
+ if (provider.supportsApiKey)
1906
+ Container(
1907
+ width: double.infinity,
1908
+ padding: const EdgeInsets.all(12),
1909
+ margin: const EdgeInsets.only(bottom: 12),
1910
+ decoration: BoxDecoration(
1911
+ color: _bgCard,
1912
+ borderRadius: BorderRadius.circular(14),
1913
+ border: Border.all(color: _border),
1914
+ ),
1915
+ child: Text(
1916
+ provider.credentialConfigured
1917
+ ? 'Credentials for this provider are already available to the runtime.'
1918
+ : 'Credentials for this provider are managed outside this workspace UI. Finish the server or admin setup, then return here to enable routing.',
1919
+ style: TextStyle(color: _textSecondary, height: 1.35),
1920
+ ),
1921
+ ),
1922
+ if (provider.supportsBaseUrl) ...<Widget>[
1923
+ TextField(
1924
+ controller: baseUrlController,
1925
+ keyboardType: TextInputType.url,
1926
+ autocorrect: false,
1927
+ decoration: InputDecoration(
1928
+ labelText: provider.id == 'ollama'
1929
+ ? 'Server URL'
1930
+ : 'Base URL',
1931
+ helperText: provider.defaultBaseUrl.trim().isEmpty
1932
+ ? 'Optional override.'
1933
+ : 'Default: ${provider.defaultBaseUrl}',
1934
+ ),
1935
+ ),
1936
+ const SizedBox(height: 12),
1937
+ ],
1938
+ if (models.isNotEmpty) ...<Widget>[
1939
+ Text('Models', style: TextStyle(fontWeight: FontWeight.w700)),
1940
+ const SizedBox(height: 8),
1941
+ Wrap(
1942
+ spacing: 8,
1943
+ runSpacing: 8,
1944
+ children: models
1945
+ .map(
1946
+ (model) => Container(
1947
+ padding: const EdgeInsets.symmetric(
1948
+ horizontal: 10,
1949
+ vertical: 8,
1950
+ ),
1951
+ decoration: BoxDecoration(
1952
+ color: model.available ? _bgCard : _bgPrimary,
1953
+ borderRadius: BorderRadius.circular(999),
1954
+ border: Border.all(
1955
+ color: model.available ? _border : _borderLight,
1956
+ ),
1957
+ ),
1958
+ child: Text(
1959
+ model.label,
1960
+ style: TextStyle(
1961
+ fontSize: 12,
1962
+ color: model.available
1963
+ ? _textPrimary
1964
+ : _textSecondary,
1965
+ ),
1966
+ ),
1967
+ ),
1968
+ )
1969
+ .toList(),
1970
+ ),
1971
+ ],
1972
+ ],
1973
+ ],
1974
+ ),
1975
+ );
1976
+ }
1977
+ }
1978
+
1979
+ class _RoutingSelectCard extends StatelessWidget {
1980
+ const _RoutingSelectCard({
1981
+ required this.label,
1982
+ required this.icon,
1983
+ required this.value,
1984
+ required this.items,
1985
+ required this.onChanged,
1986
+ });
1987
+
1988
+ final String label;
1989
+ final IconData icon;
1990
+ final String value;
1991
+ final List<DropdownMenuItem<String>> items;
1992
+ final ValueChanged<String?> onChanged;
1993
+
1994
+ @override
1995
+ Widget build(BuildContext context) {
1996
+ return Container(
1997
+ padding: const EdgeInsets.all(14),
1998
+ decoration: BoxDecoration(
1999
+ color: _bgSecondary,
2000
+ borderRadius: BorderRadius.circular(16),
2001
+ border: Border.all(color: _border),
2002
+ ),
2003
+ child: Column(
2004
+ crossAxisAlignment: CrossAxisAlignment.start,
2005
+ children: <Widget>[
2006
+ Row(
2007
+ children: <Widget>[
2008
+ Icon(icon, size: 16, color: _accentHover),
2009
+ const SizedBox(width: 8),
2010
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2011
+ ],
2012
+ ),
2013
+ const SizedBox(height: 10),
2014
+ DropdownButtonFormField<String>(
2015
+ initialValue: value,
2016
+ items: items,
2017
+ decoration: const InputDecoration(isDense: true),
2018
+ onChanged: onChanged,
2019
+ ),
2020
+ ],
2021
+ ),
2022
+ );
2023
+ }
2024
+ }