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,957 @@
1
+ part of 'main.dart';
2
+
3
+ class VoiceAssistantPanel extends StatefulWidget {
4
+ const VoiceAssistantPanel({super.key, required this.controller});
5
+
6
+ final NeoAgentController controller;
7
+
8
+ @override
9
+ State<VoiceAssistantPanel> createState() => _VoiceAssistantPanelState();
10
+ }
11
+
12
+ class _VoiceAssistantPanelState extends State<VoiceAssistantPanel> {
13
+ late final AudioPlayer _assistantPlayer;
14
+ late final AudioPlayer _thinkingPlayer;
15
+ Timer? _elapsedTimer;
16
+ bool _elapsedTickerActive = false;
17
+ bool _pttPressed = false;
18
+ bool _isAssistantPlaying = false;
19
+ bool _isThinkingAudioPlaying = false;
20
+ String _assistantReply = '';
21
+ String _assistantTranscript = '';
22
+ String? _voiceError;
23
+ String? _assistantAudioMimeType;
24
+ String? _lastLiveError;
25
+ final List<Uint8List> _audioQueue = <Uint8List>[];
26
+ late final Uint8List _thinkingAudioLoopBytes;
27
+ bool _isDraining = false;
28
+ bool _audioInterrupted = false;
29
+ int _audioQueueConsumedCount = 0;
30
+
31
+ String _liveStateLabel(VoiceAssistantLiveState state) {
32
+ switch (state.state.trim().toLowerCase()) {
33
+ case 'listening':
34
+ return 'Listening';
35
+ case 'transcribing':
36
+ return 'Transcribing';
37
+ case 'thinking':
38
+ return 'Thinking';
39
+ case 'speaking':
40
+ return 'Speaking';
41
+ case 'interrupted':
42
+ return 'Interrupted';
43
+ case 'error':
44
+ return 'Error';
45
+ default:
46
+ return 'Ready';
47
+ }
48
+ }
49
+
50
+ String _heroHintForState(
51
+ VoiceAssistantLiveState liveState,
52
+ bool liveCaptureStarting,
53
+ bool liveCaptureEngaged,
54
+ bool useDesktopToggleCapture,
55
+ ) {
56
+ if (liveCaptureEngaged) {
57
+ return useDesktopToggleCapture
58
+ ? 'Tap again to finish.'
59
+ : 'Release to finish.';
60
+ }
61
+ if (liveCaptureStarting) {
62
+ return 'Starting voice capture...';
63
+ }
64
+ switch (liveState.state.trim().toLowerCase()) {
65
+ case 'transcribing':
66
+ return 'Transcribing your speech...';
67
+ case 'thinking':
68
+ return 'Thinking...';
69
+ case 'speaking':
70
+ return 'Playing the reply...';
71
+ case 'interrupted':
72
+ return 'Reply interrupted.';
73
+ case 'error':
74
+ return 'Voice capture hit an error.';
75
+ default:
76
+ return useDesktopToggleCapture ? 'Tap to talk.' : 'Hold to talk.';
77
+ }
78
+ }
79
+
80
+ @override
81
+ void initState() {
82
+ super.initState();
83
+ _assistantPlayer = AudioPlayer();
84
+ _thinkingPlayer = AudioPlayer();
85
+ _thinkingAudioLoopBytes = _buildThinkingLoopWav();
86
+ widget.controller.addListener(_handleControllerChanged);
87
+ _assistantPlayer.onPlayerComplete.listen((_) {
88
+ if (!mounted) {
89
+ return;
90
+ }
91
+ setState(() {
92
+ _isAssistantPlaying = false;
93
+ });
94
+ unawaited(_syncThinkingAudio());
95
+ });
96
+ _syncElapsedTicker();
97
+ }
98
+
99
+ @override
100
+ void dispose() {
101
+ widget.controller.removeListener(_handleControllerChanged);
102
+ _elapsedTimer?.cancel();
103
+ unawaited(_assistantPlayer.dispose());
104
+ unawaited(_thinkingPlayer.dispose());
105
+ super.dispose();
106
+ }
107
+
108
+ void _handleControllerChanged() {
109
+ _syncElapsedTicker();
110
+ _syncLiveVoiceState();
111
+ unawaited(_syncThinkingAudio());
112
+ }
113
+
114
+ void _syncElapsedTicker() {
115
+ final shouldRun =
116
+ widget.controller.isLiveVoiceCaptureActive ||
117
+ widget.controller.isLiveVoiceCaptureStarting;
118
+ if (shouldRun == _elapsedTickerActive) {
119
+ return;
120
+ }
121
+
122
+ _elapsedTickerActive = shouldRun;
123
+ _elapsedTimer?.cancel();
124
+ if (!shouldRun) {
125
+ return;
126
+ }
127
+
128
+ _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
129
+ if (!mounted) {
130
+ return;
131
+ }
132
+ setState(() {});
133
+ });
134
+ }
135
+
136
+ void _syncLiveVoiceState() {
137
+ final liveState = widget.controller.voiceAssistantLiveState;
138
+ _assistantReply = liveState.finalAssistantText.ifEmpty(
139
+ liveState.interimAssistantText,
140
+ );
141
+ _assistantTranscript = liveState.finalTranscript.ifEmpty(
142
+ liveState.partialTranscript,
143
+ );
144
+ _assistantAudioMimeType = liveState.audioMimeType;
145
+ _voiceError = liveState.error;
146
+
147
+ final currentError = liveState.error?.trim();
148
+ if ((currentError?.isNotEmpty ?? false) && currentError != _lastLiveError) {
149
+ _lastLiveError = currentError;
150
+ _audioInterrupted = true;
151
+ _audioQueue.clear();
152
+ _audioQueueConsumedCount = 0;
153
+ unawaited(_stopAssistantAudio());
154
+ } else if (currentError == null || currentError.isEmpty) {
155
+ _lastLiveError = null;
156
+ }
157
+
158
+ // If the state queue was cleared (e.g. on interrupt), reset cursor.
159
+ final incoming = liveState.audioQueue;
160
+ if (_audioQueueConsumedCount > incoming.length) {
161
+ _audioQueueConsumedCount = 0;
162
+ }
163
+
164
+ // Only enqueue chunks we haven't seen yet.
165
+ if (incoming.length > _audioQueueConsumedCount) {
166
+ _audioInterrupted = false;
167
+ final newChunks = incoming.sublist(_audioQueueConsumedCount);
168
+ _audioQueueConsumedCount = incoming.length;
169
+ for (final chunk in newChunks) {
170
+ if (chunk.isNotEmpty) _audioQueue.add(chunk);
171
+ }
172
+ unawaited(_drainAudioQueue());
173
+ }
174
+ }
175
+
176
+ Future<void> _syncThinkingAudio() async {
177
+ final state = widget.controller.voiceAssistantLiveState.state.trim();
178
+ final shouldPlay = state == 'thinking' && !_isAssistantPlaying;
179
+ if (shouldPlay == _isThinkingAudioPlaying) {
180
+ return;
181
+ }
182
+ if (shouldPlay) {
183
+ try {
184
+ await _thinkingPlayer.setReleaseMode(ReleaseMode.loop);
185
+ await _thinkingPlayer.setVolume(0.08);
186
+ await _thinkingPlayer.play(
187
+ BytesSource(_thinkingAudioLoopBytes, mimeType: 'audio/wav'),
188
+ );
189
+ _isThinkingAudioPlaying = true;
190
+ } catch (error, stackTrace) {
191
+ AppDiagnostics.log(
192
+ 'voice.assistant.ui',
193
+ 'thinking_audio.start_failed',
194
+ error: error,
195
+ stackTrace: stackTrace,
196
+ );
197
+ }
198
+ return;
199
+ }
200
+ await _stopThinkingAudio();
201
+ }
202
+
203
+ Future<void> _stopThinkingAudio() async {
204
+ if (!_isThinkingAudioPlaying) {
205
+ return;
206
+ }
207
+ await _thinkingPlayer.stop();
208
+ _isThinkingAudioPlaying = false;
209
+ }
210
+
211
+ bool _hasActivePttCapture() {
212
+ final controller = widget.controller;
213
+ return controller.isLiveVoiceCaptureActive ||
214
+ controller.isLiveVoiceCaptureStarting;
215
+ }
216
+
217
+ void _handlePrimaryPointerDown(PointerDownEvent event) {
218
+ if (event.kind == PointerDeviceKind.mouse &&
219
+ event.buttons != kPrimaryMouseButton) {
220
+ return;
221
+ }
222
+ if (_hasActivePttCapture()) {
223
+ return;
224
+ }
225
+ unawaited(_startPttCapture());
226
+ }
227
+
228
+ void _handlePrimaryPointerUp(PointerEvent event) {
229
+ if (!_hasActivePttCapture() && !_pttPressed) {
230
+ return;
231
+ }
232
+ unawaited(_stopPttCapture());
233
+ }
234
+
235
+ Future<void> _startPttCapture() async {
236
+ AppDiagnostics.log(
237
+ 'voice.assistant.ui',
238
+ 'capture_start.request',
239
+ data: <String, Object?>{
240
+ 'hasActiveSession':
241
+ widget.controller.voiceAssistantLiveState.hasActiveSession,
242
+ },
243
+ );
244
+ setState(() {
245
+ _pttPressed = true;
246
+ _voiceError = null;
247
+ });
248
+
249
+ try {
250
+ await widget.controller.startLiveVoiceCapture();
251
+ } catch (error) {
252
+ if (!mounted) {
253
+ return;
254
+ }
255
+ setState(() {
256
+ _voiceError = widget.controller._friendlyErrorMessage(error);
257
+ });
258
+ } finally {
259
+ if (mounted) {
260
+ setState(() {
261
+ _pttPressed = false;
262
+ });
263
+ }
264
+ }
265
+ }
266
+
267
+ Future<void> _stopPttCapture() async {
268
+ AppDiagnostics.log('voice.assistant.ui', 'capture_stop.request');
269
+ await widget.controller.stopLiveVoiceCapture();
270
+ }
271
+
272
+ Future<void> _drainAudioQueue() async {
273
+ if (_isDraining) return;
274
+ _isDraining = true;
275
+ try {
276
+ while (_audioQueue.isNotEmpty && !_audioInterrupted) {
277
+ final chunk = _audioQueue.removeAt(0);
278
+ if (chunk.isEmpty) continue;
279
+ final mimeType = (_assistantAudioMimeType?.trim().isNotEmpty ?? false)
280
+ ? _assistantAudioMimeType!.trim()
281
+ : null;
282
+ // Wait for the previous clip to finish before starting the next.
283
+ final completer = Completer<void>();
284
+ late StreamSubscription<void> sub;
285
+ sub = _assistantPlayer.onPlayerComplete.listen((_) {
286
+ sub.cancel();
287
+ completer.complete();
288
+ });
289
+ await _stopThinkingAudio();
290
+ await _assistantPlayer.play(BytesSource(chunk, mimeType: mimeType));
291
+ if (!mounted || _audioInterrupted) {
292
+ sub.cancel();
293
+ break;
294
+ }
295
+ if (mounted) setState(() => _isAssistantPlaying = true);
296
+ await completer.future;
297
+ if (mounted) {
298
+ setState(() => _isAssistantPlaying = _audioQueue.isNotEmpty);
299
+ }
300
+ }
301
+ } finally {
302
+ _isDraining = false;
303
+ if (mounted && !_isAssistantPlaying) {
304
+ setState(() => _isAssistantPlaying = false);
305
+ }
306
+ }
307
+ }
308
+
309
+ Future<void> _playAssistantAudio() async {
310
+ // Legacy path — not used for live streaming but kept for any non-streaming callers.
311
+ _audioInterrupted = false;
312
+ unawaited(_drainAudioQueue());
313
+ }
314
+
315
+ Future<void> _stopAssistantAudio() async {
316
+ _audioInterrupted = true;
317
+ _audioQueue.clear();
318
+ await _stopThinkingAudio();
319
+ await _assistantPlayer.stop();
320
+ if (!mounted) {
321
+ return;
322
+ }
323
+ setState(() {
324
+ _isAssistantPlaying = false;
325
+ });
326
+ }
327
+
328
+ Uint8List _buildThinkingLoopWav() {
329
+ const sampleRate = 24000;
330
+ const durationMs = 2400;
331
+ const channelCount = 1;
332
+ const bitsPerSample = 16;
333
+ final sampleCount = (sampleRate * durationMs) ~/ 1000;
334
+ final dataLength = sampleCount * 2;
335
+ final bytes = ByteData(44 + dataLength);
336
+
337
+ void writeString(int offset, String value) {
338
+ for (var i = 0; i < value.length; i += 1) {
339
+ bytes.setUint8(offset + i, value.codeUnitAt(i));
340
+ }
341
+ }
342
+
343
+ writeString(0, 'RIFF');
344
+ bytes.setUint32(4, 36 + dataLength, Endian.little);
345
+ writeString(8, 'WAVE');
346
+ writeString(12, 'fmt ');
347
+ bytes.setUint32(16, 16, Endian.little);
348
+ bytes.setUint16(20, 1, Endian.little);
349
+ bytes.setUint16(22, channelCount, Endian.little);
350
+ bytes.setUint32(24, sampleRate, Endian.little);
351
+ bytes.setUint32(
352
+ 28,
353
+ sampleRate * channelCount * (bitsPerSample ~/ 8),
354
+ Endian.little,
355
+ );
356
+ bytes.setUint16(32, channelCount * (bitsPerSample ~/ 8), Endian.little);
357
+ bytes.setUint16(34, bitsPerSample, Endian.little);
358
+ writeString(36, 'data');
359
+ bytes.setUint32(40, dataLength, Endian.little);
360
+
361
+ final twoPi = math.pi * 2;
362
+ for (var i = 0; i < sampleCount; i += 1) {
363
+ final time = i / sampleRate;
364
+ final progress = i / sampleCount;
365
+ final eased = math.sin(progress * math.pi);
366
+ final pad =
367
+ math.sin(twoPi * 196 * time) * 0.35 +
368
+ math.sin(twoPi * 246.94 * time) * 0.2 +
369
+ math.sin(twoPi * 293.66 * time) * 0.12;
370
+ final shimmer =
371
+ math.sin(twoPi * 523.25 * time + math.sin(twoPi * 0.23 * time)) *
372
+ 0.05;
373
+ final tremolo = 0.58 + 0.42 * math.sin(twoPi * 0.45 * time);
374
+ final envelope = math.pow(eased, 1.6).toDouble() * tremolo;
375
+ final sample = ((pad + shimmer) * envelope * 1400).round().clamp(
376
+ -32768,
377
+ 32767,
378
+ );
379
+ bytes.setInt16(44 + (i * 2), sample, Endian.little);
380
+ }
381
+
382
+ return bytes.buffer.asUint8List();
383
+ }
384
+
385
+ String _activeCallElapsedLabel(NeoAgentController controller) {
386
+ final startedAt = controller.liveVoiceCaptureStartedAt;
387
+ if (startedAt == null) {
388
+ return '00:00';
389
+ }
390
+ final elapsed = DateTime.now().difference(startedAt);
391
+ final totalSeconds = math.max(0, elapsed.inSeconds);
392
+ final hours = totalSeconds ~/ 3600;
393
+ final minutes = (totalSeconds % 3600) ~/ 60;
394
+ final seconds = totalSeconds % 60;
395
+ if (hours > 0) {
396
+ return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
397
+ }
398
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
399
+ }
400
+
401
+ Widget _buildLiveSessionCard(NeoAgentController controller) {
402
+ final liveState = controller.voiceAssistantLiveState;
403
+ final preview = liveState.finalTranscript.ifEmpty(
404
+ liveState.partialTranscript,
405
+ );
406
+ final statusLabel = controller.isLiveVoiceCaptureStarting
407
+ ? 'Starting'
408
+ : liveState.hasActiveSession
409
+ ? liveState.state.isNotEmpty
410
+ ? liveState.state
411
+ : 'Ready'
412
+ : liveState.isRecoverable
413
+ ? 'Reconnecting'
414
+ : 'Idle';
415
+ final helperText = liveState.hasActiveSession
416
+ ? '${liveState.provider.toUpperCase()} • ${liveState.model}'
417
+ : liveState.isRecoverable
418
+ ? 'Reconnecting the live turn.'
419
+ : 'Open a push-to-talk session to start.';
420
+ return _VoiceAssistantSectionCard(
421
+ icon: Icons.graphic_eq_outlined,
422
+ title: 'Live Session',
423
+ subtitle: helperText,
424
+ child: Column(
425
+ crossAxisAlignment: CrossAxisAlignment.start,
426
+ children: <Widget>[
427
+ Wrap(
428
+ spacing: 10,
429
+ runSpacing: 10,
430
+ children: <Widget>[
431
+ _StatusPill(
432
+ label: statusLabel,
433
+ color: controller.isLiveVoiceCaptureStarting
434
+ ? _warning
435
+ : liveState.isBusy
436
+ ? _accent
437
+ : _success,
438
+ ),
439
+ _StatusPill(
440
+ label: _activeCallElapsedLabel(controller),
441
+ color: controller.isLiveVoiceCaptureActive ? _warning : _accent,
442
+ ),
443
+ if (liveState.hasActiveSession)
444
+ _StatusPill(
445
+ label: liveState.transportState,
446
+ color: _textSecondary,
447
+ ),
448
+ ],
449
+ ),
450
+ if (liveState.hasActiveSession) ...<Widget>[
451
+ Wrap(
452
+ spacing: 10,
453
+ runSpacing: 10,
454
+ children: <Widget>[
455
+ _StatusPill(
456
+ label: liveState.provider.toUpperCase(),
457
+ color: _accent,
458
+ ),
459
+ _StatusPill(label: liveState.model, color: _textSecondary),
460
+ ],
461
+ ),
462
+ const SizedBox(height: 14),
463
+ ],
464
+ Container(
465
+ width: double.infinity,
466
+ constraints: const BoxConstraints(minHeight: 96, maxHeight: 180),
467
+ padding: const EdgeInsets.all(14),
468
+ decoration: BoxDecoration(
469
+ color: _bgSecondary,
470
+ borderRadius: BorderRadius.circular(18),
471
+ border: Border.all(color: _border),
472
+ ),
473
+ child: SingleChildScrollView(
474
+ child: SelectableText(
475
+ preview.trim().isEmpty
476
+ ? 'Transcript appears while you speak and final text fills in as soon as transcription completes.'
477
+ : preview,
478
+ style: TextStyle(
479
+ color: preview.trim().isEmpty ? _textMuted : _textPrimary,
480
+ height: 1.45,
481
+ ),
482
+ ),
483
+ ),
484
+ ),
485
+ const SizedBox(height: 14),
486
+ Wrap(
487
+ spacing: 10,
488
+ runSpacing: 10,
489
+ children: <Widget>[
490
+ OutlinedButton.icon(
491
+ onPressed: controller.voiceAssistantLiveState.hasActiveSession
492
+ ? controller.interruptLiveVoiceAssistant
493
+ : controller.ensureLiveVoiceSession,
494
+ icon: Icon(
495
+ controller.voiceAssistantLiveState.hasActiveSession
496
+ ? Icons.stop_circle_outlined
497
+ : Icons.power_settings_new_outlined,
498
+ size: 18,
499
+ ),
500
+ label: Text(
501
+ controller.voiceAssistantLiveState.hasActiveSession
502
+ ? 'Interrupt output'
503
+ : 'Open live session',
504
+ ),
505
+ ),
506
+ OutlinedButton.icon(
507
+ onPressed: controller.voiceAssistantLiveState.hasActiveSession
508
+ ? controller.closeLiveVoiceSession
509
+ : null,
510
+ icon: const Icon(Icons.close, size: 18),
511
+ label: const Text('Close session'),
512
+ ),
513
+ ],
514
+ ),
515
+ ],
516
+ ),
517
+ );
518
+ }
519
+
520
+ @override
521
+ Widget build(BuildContext context) {
522
+ final controller = widget.controller;
523
+ final liveState = controller.voiceAssistantLiveState;
524
+ final liveCaptureStarting = controller.isLiveVoiceCaptureStarting;
525
+ final viewportSize = MediaQuery.sizeOf(context);
526
+ final heroHeight = math
527
+ .min(760, math.max(360, viewportSize.height * 0.72))
528
+ .toDouble();
529
+ final assistantUi = _DesktopAssistantControlState.fromController(
530
+ controller,
531
+ blockedHintVisible: false,
532
+ );
533
+ final liveCaptureEngaged = assistantUi.isCapturing;
534
+ final isBusy = _pttPressed || liveCaptureEngaged;
535
+ final canStart = !isBusy;
536
+ final canStop = liveCaptureEngaged;
537
+ final hasAssistantAudio = _isAssistantPlaying || _audioQueue.isNotEmpty;
538
+ final useDesktopToggleCapture = assistantUi.useToggleCapture;
539
+ final heroHint = _heroHintForState(
540
+ liveState,
541
+ liveCaptureStarting,
542
+ liveCaptureEngaged,
543
+ useDesktopToggleCapture,
544
+ );
545
+ final heroButton = useDesktopToggleCapture
546
+ ? _VoiceAssistantHeroButton(
547
+ icon: liveCaptureEngaged ? Icons.stop_rounded : Icons.mic,
548
+ color: (liveCaptureEngaged || _pttPressed)
549
+ ? _warning
550
+ : assistantUi.primaryColor,
551
+ active: liveCaptureEngaged || _pttPressed,
552
+ onTap: canStart || canStop
553
+ ? controller.toggleLiveVoiceCapture
554
+ : null,
555
+ )
556
+ : Listener(
557
+ behavior: HitTestBehavior.opaque,
558
+ onPointerDown: canStart ? _handlePrimaryPointerDown : null,
559
+ onPointerUp: (canStop || canStart) ? _handlePrimaryPointerUp : null,
560
+ onPointerCancel: (canStop || canStart)
561
+ ? _handlePrimaryPointerUp
562
+ : null,
563
+ child: _VoiceAssistantHeroButton(
564
+ icon: liveCaptureEngaged ? Icons.hearing : Icons.mic,
565
+ color: (liveCaptureEngaged || _pttPressed)
566
+ ? _warning
567
+ : assistantUi.primaryColor,
568
+ active: liveCaptureEngaged || _pttPressed,
569
+ onTap: null,
570
+ ),
571
+ );
572
+
573
+ return ListView(
574
+ padding: _pagePadding(context),
575
+ children: <Widget>[
576
+ Center(
577
+ child: ConstrainedBox(
578
+ constraints: const BoxConstraints(maxWidth: 980),
579
+ child: Column(
580
+ crossAxisAlignment: CrossAxisAlignment.stretch,
581
+ children: <Widget>[
582
+ SizedBox(
583
+ height: heroHeight,
584
+ child: Container(
585
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
586
+ decoration: BoxDecoration(
587
+ gradient: LinearGradient(
588
+ colors: <Color>[
589
+ _bgSecondary.withValues(alpha: 0.98),
590
+ _bgPrimary.withValues(alpha: 0.96),
591
+ ],
592
+ begin: Alignment.topLeft,
593
+ end: Alignment.bottomRight,
594
+ ),
595
+ borderRadius: BorderRadius.circular(28),
596
+ border: Border.all(color: _borderLight),
597
+ boxShadow: <BoxShadow>[
598
+ BoxShadow(
599
+ color: Colors.black.withValues(alpha: 0.16),
600
+ blurRadius: 26,
601
+ offset: const Offset(0, 18),
602
+ ),
603
+ ],
604
+ ),
605
+ child: Column(
606
+ crossAxisAlignment: CrossAxisAlignment.stretch,
607
+ children: <Widget>[
608
+ Align(
609
+ alignment: Alignment.topCenter,
610
+ child: Wrap(
611
+ spacing: 10,
612
+ runSpacing: 10,
613
+ alignment: WrapAlignment.center,
614
+ children: <Widget>[
615
+ _DotStatus(
616
+ label: _liveStateLabel(liveState),
617
+ color: liveState.isBusy ? _danger : _success,
618
+ ),
619
+ _StatusPill(
620
+ label: _activeCallElapsedLabel(controller),
621
+ color: liveCaptureEngaged ? _warning : _accent,
622
+ ),
623
+ ],
624
+ ),
625
+ ),
626
+ Expanded(
627
+ child: Column(
628
+ mainAxisAlignment: MainAxisAlignment.center,
629
+ children: <Widget>[
630
+ heroButton,
631
+ const SizedBox(height: 18),
632
+ Text(
633
+ heroHint,
634
+ textAlign: TextAlign.center,
635
+ style: TextStyle(
636
+ color: _textSecondary,
637
+ fontWeight: FontWeight.w600,
638
+ ),
639
+ ),
640
+ if (liveCaptureStarting) ...<Widget>[
641
+ const SizedBox(height: 10),
642
+ Text(
643
+ 'The app is preparing the microphone and session.',
644
+ textAlign: TextAlign.center,
645
+ style: TextStyle(
646
+ color: _textMuted,
647
+ height: 1.35,
648
+ ),
649
+ ),
650
+ ],
651
+ if (controller.errorMessage?.trim().isNotEmpty ??
652
+ false) ...<Widget>[
653
+ const SizedBox(height: 16),
654
+ _InlineError(message: controller.errorMessage!),
655
+ ],
656
+ if (_voiceError?.trim().isNotEmpty ??
657
+ false) ...<Widget>[
658
+ const SizedBox(height: 10),
659
+ _InlineError(message: _voiceError!),
660
+ ],
661
+ ],
662
+ ),
663
+ ),
664
+ Align(
665
+ alignment: Alignment.bottomCenter,
666
+ child: Text(
667
+ liveState.state.trim().toLowerCase() == 'idle'
668
+ ? 'Transcript and reply update below.'
669
+ : '${_liveStateLabel(liveState)} in progress.',
670
+ style: TextStyle(color: _textMuted, height: 1.4),
671
+ ),
672
+ ),
673
+ ],
674
+ ),
675
+ ),
676
+ ),
677
+ const SizedBox(height: 18),
678
+ Wrap(
679
+ spacing: 14,
680
+ runSpacing: 14,
681
+ alignment: WrapAlignment.center,
682
+ children: <Widget>[
683
+ _VoiceAssistantActionButton(
684
+ icon: _isAssistantPlaying
685
+ ? Icons.stop_circle_outlined
686
+ : Icons.play_arrow,
687
+ label: _isAssistantPlaying
688
+ ? 'Stop playback'
689
+ : 'Play reply',
690
+ onTap: hasAssistantAudio
691
+ ? (_isAssistantPlaying
692
+ ? _stopAssistantAudio
693
+ : _playAssistantAudio)
694
+ : null,
695
+ ),
696
+ _VoiceAssistantActionButton(
697
+ icon: Icons.refresh,
698
+ label: 'Refresh',
699
+ onTap: controller.ensureLiveVoiceSession,
700
+ ),
701
+ _VoiceAssistantScreenContextButton(
702
+ controller: controller,
703
+ compact: false,
704
+ ),
705
+ ],
706
+ ),
707
+ const SizedBox(height: 18),
708
+ LayoutBuilder(
709
+ builder: (context, constraints) {
710
+ final wide = constraints.maxWidth >= 820;
711
+ final liveSessionCard = _buildLiveSessionCard(controller);
712
+ final assistantReplyCard = _VoiceAssistantSectionCard(
713
+ icon: Icons.record_voice_over_outlined,
714
+ title: 'Assistant Reply',
715
+ subtitle: hasAssistantAudio
716
+ ? 'Audio reply ready for playback.'
717
+ : 'Text reply and speech status.',
718
+ child: Container(
719
+ width: double.infinity,
720
+ constraints: const BoxConstraints(minHeight: 96),
721
+ padding: const EdgeInsets.all(14),
722
+ decoration: BoxDecoration(
723
+ color: _bgSecondary,
724
+ borderRadius: BorderRadius.circular(18),
725
+ border: Border.all(color: _border),
726
+ ),
727
+ child: SelectableText(
728
+ _assistantReply.trim().isEmpty
729
+ ? 'No assistant reply yet.'
730
+ : _assistantReply,
731
+ style: TextStyle(
732
+ color: _assistantReply.trim().isEmpty
733
+ ? _textMuted
734
+ : _textPrimary,
735
+ height: 1.45,
736
+ ),
737
+ ),
738
+ ),
739
+ );
740
+ if (wide) {
741
+ return Row(
742
+ crossAxisAlignment: CrossAxisAlignment.start,
743
+ children: <Widget>[
744
+ Expanded(child: liveSessionCard),
745
+ const SizedBox(width: 18),
746
+ Expanded(child: assistantReplyCard),
747
+ ],
748
+ );
749
+ }
750
+ return Column(
751
+ crossAxisAlignment: CrossAxisAlignment.stretch,
752
+ children: <Widget>[
753
+ liveSessionCard,
754
+ const SizedBox(height: 18),
755
+ assistantReplyCard,
756
+ ],
757
+ );
758
+ },
759
+ ),
760
+ const SizedBox(height: 18),
761
+ _VoiceAssistantSectionCard(
762
+ icon: Icons.subject_outlined,
763
+ title: 'Transcript',
764
+ subtitle:
765
+ 'Speech-to-text updates as soon as they are available.',
766
+ child: Container(
767
+ width: double.infinity,
768
+ constraints: const BoxConstraints(minHeight: 96),
769
+ padding: const EdgeInsets.all(14),
770
+ decoration: BoxDecoration(
771
+ color: _bgSecondary,
772
+ borderRadius: BorderRadius.circular(18),
773
+ border: Border.all(color: _border),
774
+ ),
775
+ child: SelectableText(
776
+ _assistantTranscript.trim().isEmpty
777
+ ? 'Transcript will appear here.'
778
+ : _assistantTranscript,
779
+ style: TextStyle(
780
+ color: _assistantTranscript.trim().isEmpty
781
+ ? _textMuted
782
+ : _textPrimary,
783
+ height: 1.45,
784
+ ),
785
+ ),
786
+ ),
787
+ ),
788
+ ],
789
+ ),
790
+ ),
791
+ ),
792
+ ],
793
+ );
794
+ }
795
+ }
796
+
797
+ class _VoiceAssistantSectionCard extends StatelessWidget {
798
+ const _VoiceAssistantSectionCard({
799
+ required this.icon,
800
+ required this.title,
801
+ required this.subtitle,
802
+ required this.child,
803
+ });
804
+
805
+ final IconData icon;
806
+ final String title;
807
+ final String subtitle;
808
+ final Widget child;
809
+
810
+ @override
811
+ Widget build(BuildContext context) {
812
+ return Container(
813
+ padding: const EdgeInsets.all(18),
814
+ decoration: BoxDecoration(
815
+ color: _bgCard,
816
+ borderRadius: BorderRadius.circular(22),
817
+ border: Border.all(color: _borderLight),
818
+ boxShadow: <BoxShadow>[
819
+ BoxShadow(
820
+ color: Colors.black.withValues(alpha: 0.08),
821
+ blurRadius: 18,
822
+ offset: const Offset(0, 12),
823
+ ),
824
+ ],
825
+ ),
826
+ child: Column(
827
+ crossAxisAlignment: CrossAxisAlignment.start,
828
+ children: <Widget>[
829
+ Row(
830
+ children: <Widget>[
831
+ Container(
832
+ width: 36,
833
+ height: 36,
834
+ decoration: BoxDecoration(
835
+ color: _bgSecondary,
836
+ borderRadius: BorderRadius.circular(12),
837
+ ),
838
+ alignment: Alignment.center,
839
+ child: Icon(icon, size: 18, color: _accent),
840
+ ),
841
+ const SizedBox(width: 12),
842
+ Expanded(
843
+ child: Column(
844
+ crossAxisAlignment: CrossAxisAlignment.start,
845
+ children: <Widget>[
846
+ Text(
847
+ title,
848
+ style: TextStyle(
849
+ fontWeight: FontWeight.w700,
850
+ fontSize: 16,
851
+ ),
852
+ ),
853
+ const SizedBox(height: 3),
854
+ Text(
855
+ subtitle,
856
+ style: TextStyle(color: _textSecondary, height: 1.35),
857
+ ),
858
+ ],
859
+ ),
860
+ ),
861
+ ],
862
+ ),
863
+ const SizedBox(height: 16),
864
+ child,
865
+ ],
866
+ ),
867
+ );
868
+ }
869
+ }
870
+
871
+ class _VoiceAssistantActionButton extends StatelessWidget {
872
+ const _VoiceAssistantActionButton({
873
+ required this.icon,
874
+ required this.label,
875
+ required this.onTap,
876
+ });
877
+
878
+ final IconData icon;
879
+ final String label;
880
+ final VoidCallback? onTap;
881
+
882
+ @override
883
+ Widget build(BuildContext context) {
884
+ return Opacity(
885
+ opacity: onTap == null ? 0.45 : 1,
886
+ child: GestureDetector(
887
+ onTap: onTap,
888
+ child: Container(
889
+ constraints: const BoxConstraints(minWidth: 128),
890
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
891
+ decoration: BoxDecoration(
892
+ color: _bgCard,
893
+ borderRadius: BorderRadius.circular(18),
894
+ border: Border.all(color: _borderLight),
895
+ ),
896
+ child: Row(
897
+ mainAxisSize: MainAxisSize.min,
898
+ children: <Widget>[
899
+ Icon(icon, size: 18, color: _textPrimary),
900
+ const SizedBox(width: 10),
901
+ Flexible(
902
+ child: Text(
903
+ label,
904
+ overflow: TextOverflow.ellipsis,
905
+ style: TextStyle(
906
+ color: _textPrimary,
907
+ fontWeight: FontWeight.w600,
908
+ ),
909
+ ),
910
+ ),
911
+ ],
912
+ ),
913
+ ),
914
+ ),
915
+ );
916
+ }
917
+ }
918
+
919
+ class _VoiceAssistantHeroButton extends StatelessWidget {
920
+ const _VoiceAssistantHeroButton({
921
+ required this.icon,
922
+ required this.color,
923
+ required this.active,
924
+ required this.onTap,
925
+ });
926
+
927
+ final IconData icon;
928
+ final Color color;
929
+ final bool active;
930
+ final VoidCallback? onTap;
931
+
932
+ @override
933
+ Widget build(BuildContext context) {
934
+ return AnimatedScale(
935
+ duration: const Duration(milliseconds: 180),
936
+ curve: Curves.easeOutCubic,
937
+ scale: active ? 1.03 : 1,
938
+ child: Opacity(
939
+ opacity: onTap == null ? 0.5 : 1,
940
+ child: Material(
941
+ color: color,
942
+ shape: const CircleBorder(),
943
+ elevation: active ? 10 : 4,
944
+ child: InkWell(
945
+ customBorder: const CircleBorder(),
946
+ onTap: onTap,
947
+ child: SizedBox(
948
+ width: 140,
949
+ height: 140,
950
+ child: Icon(icon, size: 56, color: Colors.white),
951
+ ),
952
+ ),
953
+ ),
954
+ ),
955
+ );
956
+ }
957
+ }