neoagent 2.3.1-beta.2 → 2.3.1-beta.21

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 (264) hide show
  1. package/.env.example +39 -0
  2. package/README.md +2 -0
  3. package/docs/capabilities.md +2 -2
  4. package/docs/configuration.md +13 -5
  5. package/docs/integrations.md +4 -1
  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 +23057 -0
  66. package/flutter_app/lib/main_app_shell.dart +1682 -0
  67. package/flutter_app/lib/main_integrations.dart +931 -0
  68. package/flutter_app/lib/main_launcher.dart +959 -0
  69. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  70. package/flutter_app/lib/main_models.dart +3473 -0
  71. package/flutter_app/lib/main_shared.dart +2861 -0
  72. package/flutter_app/lib/main_theme.dart +204 -0
  73. package/flutter_app/lib/main_voice_assistant.dart +831 -0
  74. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  75. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  76. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  77. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  78. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  79. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  80. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  81. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  82. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  83. package/flutter_app/lib/src/backend_client.dart +1833 -0
  84. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  85. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  86. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  87. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  88. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  89. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  90. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  91. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  92. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  93. package/flutter_app/lib/src/health_bridge.dart +136 -0
  94. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  95. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  96. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  97. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  98. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  99. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  100. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  101. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  102. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  103. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  104. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  105. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  106. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  107. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  108. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  109. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  110. package/flutter_app/lib/src/theme/palette.dart +81 -0
  111. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  112. package/flutter_app/linux/CMakeLists.txt +128 -0
  113. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  114. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  115. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  116. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  117. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  118. package/flutter_app/linux/runner/main.cc +6 -0
  119. package/flutter_app/linux/runner/my_application.cc +144 -0
  120. package/flutter_app/linux/runner/my_application.h +18 -0
  121. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  122. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  123. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  124. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  125. package/flutter_app/macos/Podfile +42 -0
  126. package/flutter_app/macos/Podfile.lock +87 -0
  127. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  128. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  129. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  130. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  131. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  132. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  133. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  134. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  135. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  136. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  137. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  138. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  139. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  140. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  141. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  142. package/flutter_app/macos/Runner/Info.plist +36 -0
  143. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  144. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  145. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  146. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  147. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  148. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  149. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  150. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  151. package/flutter_app/patch_strings.py +12 -0
  152. package/flutter_app/pubspec.lock +1088 -0
  153. package/flutter_app/pubspec.yaml +53 -0
  154. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  155. package/flutter_app/test/recording_payloads_test.dart +53 -0
  156. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  157. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  158. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  159. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  160. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  161. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  162. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  163. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  164. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  165. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  166. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  167. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  168. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  169. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  170. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  171. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  172. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  173. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  174. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  175. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  176. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  177. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  178. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  179. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  180. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  181. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  182. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  183. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  184. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  185. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  186. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  187. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  188. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  189. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  190. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  191. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  192. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  193. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  194. package/flutter_app/web/favicon.png +0 -0
  195. package/flutter_app/web/favicon.svg +12 -0
  196. package/flutter_app/web/icons/Icon-192.png +0 -0
  197. package/flutter_app/web/icons/Icon-512.png +0 -0
  198. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  199. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  200. package/flutter_app/web/index.html +39 -0
  201. package/flutter_app/web/manifest.json +35 -0
  202. package/flutter_app/windows/CMakeLists.txt +108 -0
  203. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  204. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  205. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  206. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  207. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  208. package/flutter_app/windows/runner/Runner.rc +121 -0
  209. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  210. package/flutter_app/windows/runner/flutter_window.h +37 -0
  211. package/flutter_app/windows/runner/main.cpp +53 -0
  212. package/flutter_app/windows/runner/resource.h +16 -0
  213. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  214. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  215. package/flutter_app/windows/runner/utils.cpp +65 -0
  216. package/flutter_app/windows/runner/utils.h +19 -0
  217. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  218. package/flutter_app/windows/runner/win32_window.h +102 -0
  219. package/lib/manager.js +231 -7
  220. package/package.json +3 -1
  221. package/server/db/database.js +68 -0
  222. package/server/http/middleware.js +50 -0
  223. package/server/http/routes.js +3 -1
  224. package/server/index.js +1 -0
  225. package/server/public/.last_build_id +1 -1
  226. package/server/public/assets/NOTICES +61 -0
  227. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  228. package/server/public/flutter_bootstrap.js +1 -1
  229. package/server/public/main.dart.js +65262 -64422
  230. package/server/routes/integrations.js +86 -0
  231. package/server/routes/memory.js +11 -2
  232. package/server/routes/screenHistory.js +46 -0
  233. package/server/routes/triggers.js +81 -0
  234. package/server/services/ai/models.js +30 -0
  235. package/server/services/ai/providers/githubCopilot.js +97 -0
  236. package/server/services/ai/providers/openai.js +2 -1
  237. package/server/services/ai/providers/openaiCodex.js +31 -0
  238. package/server/services/ai/settings.js +20 -0
  239. package/server/services/ai/systemPrompt.js +1 -1
  240. package/server/services/ai/tools.js +35 -6
  241. package/server/services/browser/controller.js +47 -3
  242. package/server/services/desktop/screenRecorder.js +172 -0
  243. package/server/services/integrations/env.js +5 -0
  244. package/server/services/integrations/github/common.js +106 -0
  245. package/server/services/integrations/github/provider.js +499 -0
  246. package/server/services/integrations/github/repos.js +1124 -0
  247. package/server/services/integrations/home_assistant/provider.js +306 -26
  248. package/server/services/integrations/manager.js +63 -7
  249. package/server/services/integrations/oauth_provider.js +13 -6
  250. package/server/services/integrations/provider_config_store.js +76 -0
  251. package/server/services/integrations/registry.js +4 -0
  252. package/server/services/integrations/trello/provider.js +744 -0
  253. package/server/services/integrations/whatsapp/provider.js +6 -2
  254. package/server/services/manager.js +22 -0
  255. package/server/services/memory/manager.js +39 -2
  256. package/server/services/skills/base_catalog.js +33 -0
  257. package/server/services/tasks/adapters/index.js +1 -0
  258. package/server/services/tasks/adapters/manual.js +12 -0
  259. package/server/services/tasks/runtime.js +1 -1
  260. package/server/services/voice/openaiClient.js +4 -1
  261. package/server/services/voice/providers.js +2 -1
  262. package/server/services/widgets/service.js +49 -4
  263. package/server/utils/local_secrets.js +56 -0
  264. package/server/utils/logger.js +37 -9
@@ -0,0 +1,831 @@
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
+ @override
32
+ void initState() {
33
+ super.initState();
34
+ _assistantPlayer = AudioPlayer();
35
+ _thinkingPlayer = AudioPlayer();
36
+ _thinkingAudioLoopBytes = _buildThinkingLoopWav();
37
+ widget.controller.addListener(_handleControllerChanged);
38
+ _assistantPlayer.onPlayerComplete.listen((_) {
39
+ if (!mounted) {
40
+ return;
41
+ }
42
+ setState(() {
43
+ _isAssistantPlaying = false;
44
+ });
45
+ unawaited(_syncThinkingAudio());
46
+ });
47
+ _syncElapsedTicker();
48
+ }
49
+
50
+ @override
51
+ void dispose() {
52
+ widget.controller.removeListener(_handleControllerChanged);
53
+ _elapsedTimer?.cancel();
54
+ unawaited(_assistantPlayer.dispose());
55
+ unawaited(_thinkingPlayer.dispose());
56
+ super.dispose();
57
+ }
58
+
59
+ void _handleControllerChanged() {
60
+ _syncElapsedTicker();
61
+ _syncLiveVoiceState();
62
+ unawaited(_syncThinkingAudio());
63
+ }
64
+
65
+ void _syncElapsedTicker() {
66
+ final shouldRun =
67
+ widget.controller.isLiveVoiceCaptureActive ||
68
+ widget.controller.isLiveVoiceCaptureStarting;
69
+ if (shouldRun == _elapsedTickerActive) {
70
+ return;
71
+ }
72
+
73
+ _elapsedTickerActive = shouldRun;
74
+ _elapsedTimer?.cancel();
75
+ if (!shouldRun) {
76
+ return;
77
+ }
78
+
79
+ _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
80
+ if (!mounted) {
81
+ return;
82
+ }
83
+ setState(() {});
84
+ });
85
+ }
86
+
87
+ void _syncLiveVoiceState() {
88
+ final liveState = widget.controller.voiceAssistantLiveState;
89
+ _assistantReply = liveState.finalAssistantText.ifEmpty(
90
+ liveState.interimAssistantText,
91
+ );
92
+ _assistantTranscript = liveState.finalTranscript.ifEmpty(
93
+ liveState.partialTranscript,
94
+ );
95
+ _assistantAudioMimeType = liveState.audioMimeType;
96
+ _voiceError = liveState.error;
97
+
98
+ final currentError = liveState.error?.trim();
99
+ if ((currentError?.isNotEmpty ?? false) && currentError != _lastLiveError) {
100
+ _lastLiveError = currentError;
101
+ _audioInterrupted = true;
102
+ _audioQueue.clear();
103
+ _audioQueueConsumedCount = 0;
104
+ unawaited(_stopAssistantAudio());
105
+ } else if (currentError == null || currentError.isEmpty) {
106
+ _lastLiveError = null;
107
+ }
108
+
109
+ // If the state queue was cleared (e.g. on interrupt), reset cursor.
110
+ final incoming = liveState.audioQueue;
111
+ if (_audioQueueConsumedCount > incoming.length) {
112
+ _audioQueueConsumedCount = 0;
113
+ }
114
+
115
+ // Only enqueue chunks we haven't seen yet.
116
+ if (incoming.length > _audioQueueConsumedCount) {
117
+ _audioInterrupted = false;
118
+ final newChunks = incoming.sublist(_audioQueueConsumedCount);
119
+ _audioQueueConsumedCount = incoming.length;
120
+ for (final chunk in newChunks) {
121
+ if (chunk.isNotEmpty) _audioQueue.add(chunk);
122
+ }
123
+ unawaited(_drainAudioQueue());
124
+ }
125
+ }
126
+
127
+ Future<void> _syncThinkingAudio() async {
128
+ final state = widget.controller.voiceAssistantLiveState.state.trim();
129
+ final shouldPlay = state == 'thinking' && !_isAssistantPlaying;
130
+ if (shouldPlay == _isThinkingAudioPlaying) {
131
+ return;
132
+ }
133
+ if (shouldPlay) {
134
+ try {
135
+ await _thinkingPlayer.setReleaseMode(ReleaseMode.loop);
136
+ await _thinkingPlayer.setVolume(0.08);
137
+ await _thinkingPlayer.play(
138
+ BytesSource(_thinkingAudioLoopBytes, mimeType: 'audio/wav'),
139
+ );
140
+ _isThinkingAudioPlaying = true;
141
+ } catch (error, stackTrace) {
142
+ AppDiagnostics.log(
143
+ 'voice.assistant.ui',
144
+ 'thinking_audio.start_failed',
145
+ error: error,
146
+ stackTrace: stackTrace,
147
+ );
148
+ }
149
+ return;
150
+ }
151
+ await _stopThinkingAudio();
152
+ }
153
+
154
+ Future<void> _stopThinkingAudio() async {
155
+ if (!_isThinkingAudioPlaying) {
156
+ return;
157
+ }
158
+ await _thinkingPlayer.stop();
159
+ _isThinkingAudioPlaying = false;
160
+ }
161
+
162
+ bool _hasActivePttCapture() {
163
+ final controller = widget.controller;
164
+ return controller.isLiveVoiceCaptureActive ||
165
+ controller.isLiveVoiceCaptureStarting;
166
+ }
167
+
168
+ void _handlePrimaryPointerDown(PointerDownEvent event) {
169
+ if (event.kind == PointerDeviceKind.mouse &&
170
+ event.buttons != kPrimaryMouseButton) {
171
+ return;
172
+ }
173
+ if (_hasActivePttCapture()) {
174
+ return;
175
+ }
176
+ unawaited(_startPttCapture());
177
+ }
178
+
179
+ void _handlePrimaryPointerUp(PointerEvent event) {
180
+ if (!_hasActivePttCapture() && !_pttPressed) {
181
+ return;
182
+ }
183
+ unawaited(_stopPttCapture());
184
+ }
185
+
186
+ Future<void> _startPttCapture() async {
187
+ AppDiagnostics.log(
188
+ 'voice.assistant.ui',
189
+ 'capture_start.request',
190
+ data: <String, Object?>{
191
+ 'hasActiveSession':
192
+ widget.controller.voiceAssistantLiveState.hasActiveSession,
193
+ },
194
+ );
195
+ setState(() {
196
+ _pttPressed = true;
197
+ _voiceError = null;
198
+ });
199
+
200
+ try {
201
+ await widget.controller.startLiveVoiceCapture();
202
+ } catch (error) {
203
+ if (!mounted) {
204
+ return;
205
+ }
206
+ setState(() {
207
+ _voiceError = widget.controller._friendlyErrorMessage(error);
208
+ });
209
+ } finally {
210
+ if (mounted) {
211
+ setState(() {
212
+ _pttPressed = false;
213
+ });
214
+ }
215
+ }
216
+ }
217
+
218
+ Future<void> _stopPttCapture() async {
219
+ AppDiagnostics.log('voice.assistant.ui', 'capture_stop.request');
220
+ await widget.controller.stopLiveVoiceCapture();
221
+ }
222
+
223
+ Future<void> _drainAudioQueue() async {
224
+ if (_isDraining) return;
225
+ _isDraining = true;
226
+ try {
227
+ while (_audioQueue.isNotEmpty && !_audioInterrupted) {
228
+ final chunk = _audioQueue.removeAt(0);
229
+ if (chunk.isEmpty) continue;
230
+ final mimeType = (_assistantAudioMimeType?.trim().isNotEmpty ?? false)
231
+ ? _assistantAudioMimeType!.trim()
232
+ : null;
233
+ // Wait for the previous clip to finish before starting the next.
234
+ final completer = Completer<void>();
235
+ late StreamSubscription<void> sub;
236
+ sub = _assistantPlayer.onPlayerComplete.listen((_) {
237
+ sub.cancel();
238
+ completer.complete();
239
+ });
240
+ await _stopThinkingAudio();
241
+ await _assistantPlayer.play(BytesSource(chunk, mimeType: mimeType));
242
+ if (!mounted || _audioInterrupted) {
243
+ sub.cancel();
244
+ break;
245
+ }
246
+ if (mounted) setState(() => _isAssistantPlaying = true);
247
+ await completer.future;
248
+ if (mounted)
249
+ setState(() => _isAssistantPlaying = _audioQueue.isNotEmpty);
250
+ }
251
+ } finally {
252
+ _isDraining = false;
253
+ if (mounted && !_isAssistantPlaying)
254
+ setState(() => _isAssistantPlaying = false);
255
+ }
256
+ }
257
+
258
+ Future<void> _playAssistantAudio() async {
259
+ // Legacy path — not used for live streaming but kept for any non-streaming callers.
260
+ _audioInterrupted = false;
261
+ unawaited(_drainAudioQueue());
262
+ }
263
+
264
+ Future<void> _stopAssistantAudio() async {
265
+ _audioInterrupted = true;
266
+ _audioQueue.clear();
267
+ await _stopThinkingAudio();
268
+ await _assistantPlayer.stop();
269
+ if (!mounted) {
270
+ return;
271
+ }
272
+ setState(() {
273
+ _isAssistantPlaying = false;
274
+ });
275
+ }
276
+
277
+ Uint8List _buildThinkingLoopWav() {
278
+ const sampleRate = 24000;
279
+ const durationMs = 2400;
280
+ const channelCount = 1;
281
+ const bitsPerSample = 16;
282
+ final sampleCount = (sampleRate * durationMs) ~/ 1000;
283
+ final dataLength = sampleCount * 2;
284
+ final bytes = ByteData(44 + dataLength);
285
+
286
+ void writeString(int offset, String value) {
287
+ for (var i = 0; i < value.length; i += 1) {
288
+ bytes.setUint8(offset + i, value.codeUnitAt(i));
289
+ }
290
+ }
291
+
292
+ writeString(0, 'RIFF');
293
+ bytes.setUint32(4, 36 + dataLength, Endian.little);
294
+ writeString(8, 'WAVE');
295
+ writeString(12, 'fmt ');
296
+ bytes.setUint32(16, 16, Endian.little);
297
+ bytes.setUint16(20, 1, Endian.little);
298
+ bytes.setUint16(22, channelCount, Endian.little);
299
+ bytes.setUint32(24, sampleRate, Endian.little);
300
+ bytes.setUint32(
301
+ 28,
302
+ sampleRate * channelCount * (bitsPerSample ~/ 8),
303
+ Endian.little,
304
+ );
305
+ bytes.setUint16(32, channelCount * (bitsPerSample ~/ 8), Endian.little);
306
+ bytes.setUint16(34, bitsPerSample, Endian.little);
307
+ writeString(36, 'data');
308
+ bytes.setUint32(40, dataLength, Endian.little);
309
+
310
+ final twoPi = math.pi * 2;
311
+ for (var i = 0; i < sampleCount; i += 1) {
312
+ final time = i / sampleRate;
313
+ final progress = i / sampleCount;
314
+ final eased = math.sin(progress * math.pi);
315
+ final pad =
316
+ math.sin(twoPi * 196 * time) * 0.35 +
317
+ math.sin(twoPi * 246.94 * time) * 0.2 +
318
+ math.sin(twoPi * 293.66 * time) * 0.12;
319
+ final shimmer =
320
+ math.sin(twoPi * 523.25 * time + math.sin(twoPi * 0.23 * time)) *
321
+ 0.05;
322
+ final tremolo = 0.58 + 0.42 * math.sin(twoPi * 0.45 * time);
323
+ final envelope = math.pow(eased, 1.6).toDouble() * tremolo;
324
+ final sample = ((pad + shimmer) * envelope * 1400).round().clamp(
325
+ -32768,
326
+ 32767,
327
+ );
328
+ bytes.setInt16(44 + (i * 2), sample, Endian.little);
329
+ }
330
+
331
+ return bytes.buffer.asUint8List();
332
+ }
333
+
334
+ String _activeCallElapsedLabel(NeoAgentController controller) {
335
+ final startedAt = controller.liveVoiceCaptureStartedAt;
336
+ if (startedAt == null) {
337
+ return '00:00';
338
+ }
339
+ final elapsed = DateTime.now().difference(startedAt);
340
+ final totalSeconds = math.max(0, elapsed.inSeconds);
341
+ final hours = totalSeconds ~/ 3600;
342
+ final minutes = (totalSeconds % 3600) ~/ 60;
343
+ final seconds = totalSeconds % 60;
344
+ if (hours > 0) {
345
+ return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
346
+ }
347
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
348
+ }
349
+
350
+ Widget _buildLiveSessionCard(NeoAgentController controller) {
351
+ final liveState = controller.voiceAssistantLiveState;
352
+ final preview = liveState.finalTranscript.ifEmpty(
353
+ liveState.partialTranscript,
354
+ );
355
+ final helperText = liveState.hasActiveSession
356
+ ? '${liveState.provider.toUpperCase()} • ${liveState.model} • ${liveState.state} • ${liveState.transportState}'
357
+ : liveState.isRecoverable
358
+ ? 'Reconnecting live voice turn...'
359
+ : 'Open a push-to-talk session to start live voice.';
360
+ return _VoiceAssistantSectionCard(
361
+ icon: Icons.graphic_eq_outlined,
362
+ title: 'Live Session',
363
+ subtitle: helperText,
364
+ child: Column(
365
+ crossAxisAlignment: CrossAxisAlignment.start,
366
+ children: <Widget>[
367
+ if (liveState.hasActiveSession) ...<Widget>[
368
+ Wrap(
369
+ spacing: 10,
370
+ runSpacing: 10,
371
+ children: <Widget>[
372
+ _StatusPill(
373
+ label: liveState.provider.toUpperCase(),
374
+ color: _accent,
375
+ ),
376
+ _StatusPill(label: liveState.model, color: _textSecondary),
377
+ ],
378
+ ),
379
+ const SizedBox(height: 14),
380
+ ],
381
+ Text(
382
+ preview.trim().isEmpty
383
+ ? 'Partial and final transcript text will appear here while the turn is in progress.'
384
+ : preview,
385
+ maxLines: 6,
386
+ overflow: TextOverflow.ellipsis,
387
+ style: TextStyle(
388
+ color: preview.trim().isEmpty ? _textMuted : _textPrimary,
389
+ height: 1.5,
390
+ ),
391
+ ),
392
+ const SizedBox(height: 14),
393
+ Wrap(
394
+ spacing: 10,
395
+ runSpacing: 10,
396
+ children: <Widget>[
397
+ OutlinedButton.icon(
398
+ onPressed: controller.voiceAssistantLiveState.hasActiveSession
399
+ ? controller.interruptLiveVoiceAssistant
400
+ : controller.ensureLiveVoiceSession,
401
+ icon: Icon(
402
+ controller.voiceAssistantLiveState.hasActiveSession
403
+ ? Icons.stop_circle_outlined
404
+ : Icons.power_settings_new_outlined,
405
+ size: 18,
406
+ ),
407
+ label: Text(
408
+ controller.voiceAssistantLiveState.hasActiveSession
409
+ ? 'Interrupt output'
410
+ : 'Open live session',
411
+ ),
412
+ ),
413
+ OutlinedButton.icon(
414
+ onPressed: controller.voiceAssistantLiveState.hasActiveSession
415
+ ? controller.closeLiveVoiceSession
416
+ : null,
417
+ icon: const Icon(Icons.close, size: 18),
418
+ label: const Text('Close session'),
419
+ ),
420
+ ],
421
+ ),
422
+ ],
423
+ ),
424
+ );
425
+ }
426
+
427
+ @override
428
+ Widget build(BuildContext context) {
429
+ final controller = widget.controller;
430
+ final liveState = controller.voiceAssistantLiveState;
431
+ final viewportSize = MediaQuery.sizeOf(context);
432
+ final heroHeight = math
433
+ .min(760, math.max(360, viewportSize.height * 0.72))
434
+ .toDouble();
435
+ final assistantUi = _DesktopAssistantControlState.fromController(
436
+ controller,
437
+ blockedHintVisible: false,
438
+ );
439
+ final liveCaptureEngaged = assistantUi.isCapturing;
440
+ final isBusy = _pttPressed || liveCaptureEngaged;
441
+ final canStart = !isBusy;
442
+ final canStop = liveCaptureEngaged;
443
+ final hasAssistantAudio = _isAssistantPlaying || _audioQueue.isNotEmpty;
444
+ final useDesktopToggleCapture = assistantUi.useToggleCapture;
445
+ final heroHint = liveCaptureEngaged
446
+ ? (useDesktopToggleCapture
447
+ ? 'Tap again to finish.'
448
+ : 'Release to finish.')
449
+ : (useDesktopToggleCapture ? 'Tap to talk.' : 'Hold to talk.');
450
+ final heroButton = useDesktopToggleCapture
451
+ ? _VoiceAssistantHeroButton(
452
+ icon: liveCaptureEngaged ? Icons.stop_rounded : Icons.mic,
453
+ color: (liveCaptureEngaged || _pttPressed)
454
+ ? _warning
455
+ : assistantUi.primaryColor,
456
+ active: liveCaptureEngaged || _pttPressed,
457
+ onTap: canStart || canStop
458
+ ? controller.toggleLiveVoiceCapture
459
+ : null,
460
+ )
461
+ : Listener(
462
+ behavior: HitTestBehavior.opaque,
463
+ onPointerDown: canStart ? _handlePrimaryPointerDown : null,
464
+ onPointerUp: (canStop || canStart) ? _handlePrimaryPointerUp : null,
465
+ onPointerCancel: (canStop || canStart)
466
+ ? _handlePrimaryPointerUp
467
+ : null,
468
+ child: _VoiceAssistantHeroButton(
469
+ icon: liveCaptureEngaged ? Icons.hearing : Icons.mic,
470
+ color: (liveCaptureEngaged || _pttPressed)
471
+ ? _warning
472
+ : assistantUi.primaryColor,
473
+ active: liveCaptureEngaged || _pttPressed,
474
+ onTap: null,
475
+ ),
476
+ );
477
+
478
+ return ListView(
479
+ padding: _pagePadding(context),
480
+ children: <Widget>[
481
+ Center(
482
+ child: ConstrainedBox(
483
+ constraints: const BoxConstraints(maxWidth: 980),
484
+ child: Column(
485
+ crossAxisAlignment: CrossAxisAlignment.stretch,
486
+ children: <Widget>[
487
+ SizedBox(
488
+ height: heroHeight,
489
+ child: Container(
490
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
491
+ decoration: BoxDecoration(
492
+ gradient: LinearGradient(
493
+ colors: <Color>[
494
+ _bgSecondary.withValues(alpha: 0.98),
495
+ _bgPrimary.withValues(alpha: 0.96),
496
+ ],
497
+ begin: Alignment.topLeft,
498
+ end: Alignment.bottomRight,
499
+ ),
500
+ borderRadius: BorderRadius.circular(28),
501
+ border: Border.all(color: _borderLight),
502
+ boxShadow: <BoxShadow>[
503
+ BoxShadow(
504
+ color: Colors.black.withValues(alpha: 0.16),
505
+ blurRadius: 26,
506
+ offset: const Offset(0, 18),
507
+ ),
508
+ ],
509
+ ),
510
+ child: Column(
511
+ crossAxisAlignment: CrossAxisAlignment.stretch,
512
+ children: <Widget>[
513
+ Align(
514
+ alignment: Alignment.topCenter,
515
+ child: Wrap(
516
+ spacing: 10,
517
+ runSpacing: 10,
518
+ alignment: WrapAlignment.center,
519
+ children: <Widget>[
520
+ _DotStatus(
521
+ label: liveState.state.isEmpty
522
+ ? 'Standby'
523
+ : liveState.state,
524
+ color: liveState.isBusy ? _danger : _success,
525
+ ),
526
+ _StatusPill(
527
+ label: _activeCallElapsedLabel(controller),
528
+ color: liveCaptureEngaged ? _warning : _accent,
529
+ ),
530
+ ],
531
+ ),
532
+ ),
533
+ Expanded(
534
+ child: Column(
535
+ mainAxisAlignment: MainAxisAlignment.center,
536
+ children: <Widget>[
537
+ heroButton,
538
+ const SizedBox(height: 18),
539
+ Text(
540
+ heroHint,
541
+ textAlign: TextAlign.center,
542
+ style: TextStyle(
543
+ color: _textSecondary,
544
+ fontWeight: FontWeight.w600,
545
+ ),
546
+ ),
547
+ if (controller.errorMessage?.trim().isNotEmpty ??
548
+ false) ...<Widget>[
549
+ const SizedBox(height: 16),
550
+ _InlineError(message: controller.errorMessage!),
551
+ ],
552
+ if (_voiceError?.trim().isNotEmpty ??
553
+ false) ...<Widget>[
554
+ const SizedBox(height: 10),
555
+ _InlineError(message: _voiceError!),
556
+ ],
557
+ ],
558
+ ),
559
+ ),
560
+ Align(
561
+ alignment: Alignment.bottomCenter,
562
+ child: Text(
563
+ 'Scroll for details',
564
+ style: TextStyle(color: _textMuted, height: 1.4),
565
+ ),
566
+ ),
567
+ ],
568
+ ),
569
+ ),
570
+ ),
571
+ const SizedBox(height: 18),
572
+ Wrap(
573
+ spacing: 14,
574
+ runSpacing: 14,
575
+ alignment: WrapAlignment.center,
576
+ children: <Widget>[
577
+ _VoiceAssistantActionButton(
578
+ icon: _isAssistantPlaying
579
+ ? Icons.stop_circle_outlined
580
+ : Icons.play_arrow,
581
+ label: _isAssistantPlaying
582
+ ? 'Stop playback'
583
+ : 'Play reply',
584
+ onTap: hasAssistantAudio
585
+ ? (_isAssistantPlaying
586
+ ? _stopAssistantAudio
587
+ : _playAssistantAudio)
588
+ : null,
589
+ ),
590
+ _VoiceAssistantActionButton(
591
+ icon: Icons.refresh,
592
+ label: 'Refresh',
593
+ onTap: controller.ensureLiveVoiceSession,
594
+ ),
595
+ _VoiceAssistantScreenContextButton(
596
+ controller: controller,
597
+ compact: false,
598
+ ),
599
+ ],
600
+ ),
601
+ const SizedBox(height: 18),
602
+ LayoutBuilder(
603
+ builder: (context, constraints) {
604
+ final wide = constraints.maxWidth >= 820;
605
+ final liveSessionCard = _buildLiveSessionCard(controller);
606
+ final assistantReplyCard = _VoiceAssistantSectionCard(
607
+ icon: Icons.record_voice_over_outlined,
608
+ title: 'Assistant Reply',
609
+ subtitle: hasAssistantAudio
610
+ ? 'Audio reply ready for playback.'
611
+ : 'Text reply and speech status.',
612
+ child: Text(
613
+ _assistantReply.trim().isEmpty
614
+ ? 'No assistant reply yet.'
615
+ : _assistantReply,
616
+ style: TextStyle(
617
+ color: _assistantReply.trim().isEmpty
618
+ ? _textMuted
619
+ : _textPrimary,
620
+ height: 1.5,
621
+ ),
622
+ ),
623
+ );
624
+ if (wide) {
625
+ return Row(
626
+ crossAxisAlignment: CrossAxisAlignment.start,
627
+ children: <Widget>[
628
+ Expanded(child: liveSessionCard),
629
+ const SizedBox(width: 18),
630
+ Expanded(child: assistantReplyCard),
631
+ ],
632
+ );
633
+ }
634
+ return Column(
635
+ crossAxisAlignment: CrossAxisAlignment.stretch,
636
+ children: <Widget>[
637
+ liveSessionCard,
638
+ const SizedBox(height: 18),
639
+ assistantReplyCard,
640
+ ],
641
+ );
642
+ },
643
+ ),
644
+ const SizedBox(height: 18),
645
+ _VoiceAssistantSectionCard(
646
+ icon: Icons.subject_outlined,
647
+ title: 'Transcript',
648
+ subtitle:
649
+ 'Partial and final transcript text for the live turn.',
650
+ child: Text(
651
+ _assistantTranscript.trim().isEmpty
652
+ ? 'Transcript will appear while or after you finish the live turn.'
653
+ : _assistantTranscript,
654
+ style: TextStyle(
655
+ color: _assistantTranscript.trim().isEmpty
656
+ ? _textMuted
657
+ : _textPrimary,
658
+ height: 1.5,
659
+ ),
660
+ ),
661
+ ),
662
+ ],
663
+ ),
664
+ ),
665
+ ),
666
+ ],
667
+ );
668
+ }
669
+ }
670
+
671
+ class _VoiceAssistantSectionCard extends StatelessWidget {
672
+ const _VoiceAssistantSectionCard({
673
+ required this.icon,
674
+ required this.title,
675
+ required this.subtitle,
676
+ required this.child,
677
+ });
678
+
679
+ final IconData icon;
680
+ final String title;
681
+ final String subtitle;
682
+ final Widget child;
683
+
684
+ @override
685
+ Widget build(BuildContext context) {
686
+ return Container(
687
+ padding: const EdgeInsets.all(18),
688
+ decoration: BoxDecoration(
689
+ color: _bgCard,
690
+ borderRadius: BorderRadius.circular(22),
691
+ border: Border.all(color: _borderLight),
692
+ boxShadow: <BoxShadow>[
693
+ BoxShadow(
694
+ color: Colors.black.withValues(alpha: 0.08),
695
+ blurRadius: 18,
696
+ offset: const Offset(0, 12),
697
+ ),
698
+ ],
699
+ ),
700
+ child: Column(
701
+ crossAxisAlignment: CrossAxisAlignment.start,
702
+ children: <Widget>[
703
+ Row(
704
+ children: <Widget>[
705
+ Container(
706
+ width: 36,
707
+ height: 36,
708
+ decoration: BoxDecoration(
709
+ color: _bgSecondary,
710
+ borderRadius: BorderRadius.circular(12),
711
+ ),
712
+ alignment: Alignment.center,
713
+ child: Icon(icon, size: 18, color: _accent),
714
+ ),
715
+ const SizedBox(width: 12),
716
+ Expanded(
717
+ child: Column(
718
+ crossAxisAlignment: CrossAxisAlignment.start,
719
+ children: <Widget>[
720
+ Text(
721
+ title,
722
+ style: TextStyle(
723
+ fontWeight: FontWeight.w700,
724
+ fontSize: 16,
725
+ ),
726
+ ),
727
+ const SizedBox(height: 3),
728
+ Text(
729
+ subtitle,
730
+ style: TextStyle(color: _textSecondary, height: 1.35),
731
+ ),
732
+ ],
733
+ ),
734
+ ),
735
+ ],
736
+ ),
737
+ const SizedBox(height: 16),
738
+ child,
739
+ ],
740
+ ),
741
+ );
742
+ }
743
+ }
744
+
745
+ class _VoiceAssistantActionButton extends StatelessWidget {
746
+ const _VoiceAssistantActionButton({
747
+ required this.icon,
748
+ required this.label,
749
+ required this.onTap,
750
+ });
751
+
752
+ final IconData icon;
753
+ final String label;
754
+ final VoidCallback? onTap;
755
+
756
+ @override
757
+ Widget build(BuildContext context) {
758
+ return Opacity(
759
+ opacity: onTap == null ? 0.45 : 1,
760
+ child: GestureDetector(
761
+ onTap: onTap,
762
+ child: Container(
763
+ constraints: const BoxConstraints(minWidth: 128),
764
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
765
+ decoration: BoxDecoration(
766
+ color: _bgCard,
767
+ borderRadius: BorderRadius.circular(18),
768
+ border: Border.all(color: _borderLight),
769
+ ),
770
+ child: Row(
771
+ mainAxisSize: MainAxisSize.min,
772
+ children: <Widget>[
773
+ Icon(icon, size: 18, color: _textPrimary),
774
+ const SizedBox(width: 10),
775
+ Flexible(
776
+ child: Text(
777
+ label,
778
+ overflow: TextOverflow.ellipsis,
779
+ style: TextStyle(
780
+ color: _textPrimary,
781
+ fontWeight: FontWeight.w600,
782
+ ),
783
+ ),
784
+ ),
785
+ ],
786
+ ),
787
+ ),
788
+ ),
789
+ );
790
+ }
791
+ }
792
+
793
+ class _VoiceAssistantHeroButton extends StatelessWidget {
794
+ const _VoiceAssistantHeroButton({
795
+ required this.icon,
796
+ required this.color,
797
+ required this.active,
798
+ required this.onTap,
799
+ });
800
+
801
+ final IconData icon;
802
+ final Color color;
803
+ final bool active;
804
+ final VoidCallback? onTap;
805
+
806
+ @override
807
+ Widget build(BuildContext context) {
808
+ return AnimatedScale(
809
+ duration: const Duration(milliseconds: 180),
810
+ curve: Curves.easeOutCubic,
811
+ scale: active ? 1.03 : 1,
812
+ child: Opacity(
813
+ opacity: onTap == null ? 0.5 : 1,
814
+ child: Material(
815
+ color: color,
816
+ shape: const CircleBorder(),
817
+ elevation: active ? 10 : 4,
818
+ child: InkWell(
819
+ customBorder: const CircleBorder(),
820
+ onTap: onTap,
821
+ child: SizedBox(
822
+ width: 140,
823
+ height: 140,
824
+ child: Icon(icon, size: 56, color: Colors.white),
825
+ ),
826
+ ),
827
+ ),
828
+ ),
829
+ );
830
+ }
831
+ }