neoagent 2.3.1-beta.20 → 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 (215) hide show
  1. package/flutter_app/.metadata +42 -0
  2. package/flutter_app/README.md +21 -0
  3. package/flutter_app/analysis_options.yaml +32 -0
  4. package/flutter_app/android/app/build.gradle.kts +109 -0
  5. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  6. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  7. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  8. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  9. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  10. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  11. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  25. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  26. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  27. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  28. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  29. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  30. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  31. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  32. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  33. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  34. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  35. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  36. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  37. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  38. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  39. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  40. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  41. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  42. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  43. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  44. package/flutter_app/android/build.gradle.kts +24 -0
  45. package/flutter_app/android/ci-release.keystore +0 -0
  46. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  47. package/flutter_app/android/gradle.properties +3 -0
  48. package/flutter_app/android/key.properties +4 -0
  49. package/flutter_app/android/settings.gradle.kts +26 -0
  50. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  51. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  52. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  53. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  54. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  55. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  57. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  58. package/flutter_app/lib/features/location/location_service.dart +119 -0
  59. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  60. package/flutter_app/lib/main.dart +23057 -0
  61. package/flutter_app/lib/main_app_shell.dart +1682 -0
  62. package/flutter_app/lib/main_integrations.dart +931 -0
  63. package/flutter_app/lib/main_launcher.dart +959 -0
  64. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  65. package/flutter_app/lib/main_models.dart +3473 -0
  66. package/flutter_app/lib/main_shared.dart +2861 -0
  67. package/flutter_app/lib/main_theme.dart +204 -0
  68. package/flutter_app/lib/main_voice_assistant.dart +831 -0
  69. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  70. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  71. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  72. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  73. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  74. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  75. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  76. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  77. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  78. package/flutter_app/lib/src/backend_client.dart +1833 -0
  79. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  80. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  81. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  82. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  83. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  84. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  85. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  86. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  87. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  88. package/flutter_app/lib/src/health_bridge.dart +136 -0
  89. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  90. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  91. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  92. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  93. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  94. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  95. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  96. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  97. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  98. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  99. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  100. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  101. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  102. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  103. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  104. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  105. package/flutter_app/lib/src/theme/palette.dart +81 -0
  106. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  107. package/flutter_app/linux/CMakeLists.txt +128 -0
  108. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  109. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  110. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  111. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  112. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  113. package/flutter_app/linux/runner/main.cc +6 -0
  114. package/flutter_app/linux/runner/my_application.cc +144 -0
  115. package/flutter_app/linux/runner/my_application.h +18 -0
  116. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  117. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  118. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  119. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  120. package/flutter_app/macos/Podfile +42 -0
  121. package/flutter_app/macos/Podfile.lock +87 -0
  122. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  123. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  124. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  125. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  126. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  127. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  128. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  129. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  130. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  131. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  132. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  133. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  134. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  135. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  136. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  137. package/flutter_app/macos/Runner/Info.plist +36 -0
  138. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  139. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  140. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  141. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  142. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  143. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  144. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  145. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  146. package/flutter_app/patch_strings.py +12 -0
  147. package/flutter_app/pubspec.lock +1088 -0
  148. package/flutter_app/pubspec.yaml +53 -0
  149. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  150. package/flutter_app/test/recording_payloads_test.dart +53 -0
  151. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  152. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  153. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  154. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  155. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  156. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  157. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  158. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  159. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  160. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  161. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  162. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  163. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  164. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  165. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  166. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  167. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  168. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  169. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  170. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  171. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  172. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  173. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  174. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  175. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  176. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  177. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  178. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  179. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  180. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  181. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  182. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  183. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  184. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  185. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  186. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  187. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  188. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  189. package/flutter_app/web/favicon.png +0 -0
  190. package/flutter_app/web/favicon.svg +12 -0
  191. package/flutter_app/web/icons/Icon-192.png +0 -0
  192. package/flutter_app/web/icons/Icon-512.png +0 -0
  193. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  194. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  195. package/flutter_app/web/index.html +39 -0
  196. package/flutter_app/web/manifest.json +35 -0
  197. package/flutter_app/windows/CMakeLists.txt +108 -0
  198. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  199. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  200. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  201. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  202. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  203. package/flutter_app/windows/runner/Runner.rc +121 -0
  204. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  205. package/flutter_app/windows/runner/flutter_window.h +37 -0
  206. package/flutter_app/windows/runner/main.cpp +53 -0
  207. package/flutter_app/windows/runner/resource.h +16 -0
  208. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  209. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  210. package/flutter_app/windows/runner/utils.cpp +65 -0
  211. package/flutter_app/windows/runner/utils.h +19 -0
  212. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  213. package/flutter_app/windows/runner/win32_window.h +102 -0
  214. package/package.json +2 -1
  215. package/server/public/flutter_bootstrap.js +1 -1
@@ -0,0 +1,2861 @@
1
+ part of 'main.dart';
2
+
3
+ EdgeInsets _pagePadding(BuildContext context) {
4
+ final width = MediaQuery.sizeOf(context).width;
5
+ if (width >= 1280) {
6
+ return const EdgeInsets.fromLTRB(40, 34, 40, 40);
7
+ }
8
+ if (width >= 900) {
9
+ return const EdgeInsets.fromLTRB(30, 28, 30, 32);
10
+ }
11
+ return const EdgeInsets.fromLTRB(20, 20, 20, 28);
12
+ }
13
+
14
+ class _AmbientBackdrop extends StatelessWidget {
15
+ const _AmbientBackdrop({required this.child});
16
+
17
+ final Widget child;
18
+
19
+ @override
20
+ Widget build(BuildContext context) {
21
+ return DecoratedBox(
22
+ decoration: BoxDecoration(gradient: _appBackgroundGradient),
23
+ child: Stack(
24
+ children: <Widget>[
25
+ Positioned(
26
+ top: -120,
27
+ left: -90,
28
+ child: _BlurOrb(size: 340, color: _accent.withValues(alpha: 0.9)),
29
+ ),
30
+ Positioned(
31
+ top: 90,
32
+ right: -120,
33
+ child: _BlurOrb(
34
+ size: 280,
35
+ color: _accentAlt.withValues(alpha: 0.85),
36
+ ),
37
+ ),
38
+ Positioned(
39
+ bottom: -140,
40
+ left: 100,
41
+ child: _BlurOrb(size: 360, color: _accent.withValues(alpha: 0.45)),
42
+ ),
43
+ Positioned.fill(
44
+ child: IgnorePointer(
45
+ child: DecoratedBox(
46
+ decoration: BoxDecoration(
47
+ gradient: LinearGradient(
48
+ colors: <Color>[
49
+ Colors.white.withValues(alpha: 0.02),
50
+ Colors.transparent,
51
+ Colors.black.withValues(alpha: 0.08),
52
+ ],
53
+ begin: Alignment.topCenter,
54
+ end: Alignment.bottomCenter,
55
+ ),
56
+ ),
57
+ ),
58
+ ),
59
+ ),
60
+ child,
61
+ ],
62
+ ),
63
+ );
64
+ }
65
+ }
66
+
67
+ List<AppSection> _mainSections(NeoAgentController controller) {
68
+ return <AppSection>[
69
+ AppSection.chat,
70
+ AppSection.recordings,
71
+ AppSection.runs,
72
+ AppSection.logs,
73
+ AppSection.devices,
74
+ AppSection.tasks,
75
+ AppSection.widgets,
76
+ AppSection.skills,
77
+ AppSection.integrations,
78
+ AppSection.mcp,
79
+ AppSection.memory,
80
+ if (controller.showHealthSection) AppSection.health,
81
+ AppSection.settings,
82
+ AppSection.agents,
83
+ AppSection.messaging,
84
+ ];
85
+ }
86
+
87
+ List<Widget> _buildSidebarItems(
88
+ NeoAgentController controller, {
89
+ required ValueChanged<AppSection> onSelect,
90
+ required SidebarGroup? expandedGroup,
91
+ required ValueChanged<SidebarGroup> onToggleGroup,
92
+ }) {
93
+ final widgets = <Widget>[];
94
+ final mainSections = _mainSections(controller);
95
+ final selectedSidebarSection = mainSections.contains(
96
+ controller.selectedSection,
97
+ );
98
+ for (final group in SidebarGroup.values) {
99
+ final sections = mainSections
100
+ .where((section) => section.group == group)
101
+ .toList();
102
+ if (sections.isEmpty) {
103
+ continue;
104
+ }
105
+
106
+ final active =
107
+ selectedSidebarSection && controller.selectedSection.group == group;
108
+ final defaultSection = sections.first;
109
+ final hasChildren = sections.length > 1;
110
+ final expanded = expandedGroup == group;
111
+
112
+ widgets.add(
113
+ _SidebarButton(
114
+ label: group.label,
115
+ icon: group.icon,
116
+ active: active,
117
+ trailing: hasChildren
118
+ ? Icon(
119
+ expanded ? Icons.expand_less : Icons.expand_more,
120
+ size: 16,
121
+ color: active ? _accent : _textMuted,
122
+ )
123
+ : null,
124
+ onTap: hasChildren
125
+ ? () => onToggleGroup(group)
126
+ : () => onSelect(defaultSection),
127
+ ),
128
+ );
129
+
130
+ if (!hasChildren || !expanded) {
131
+ continue;
132
+ }
133
+
134
+ for (final section in sections) {
135
+ widgets.add(
136
+ _SidebarButton(
137
+ label: section.label,
138
+ icon: section.icon,
139
+ active: controller.selectedSection == section,
140
+ indent: 18,
141
+ iconSize: 16,
142
+ fontSize: 12,
143
+ onTap: () => onSelect(section),
144
+ ),
145
+ );
146
+ }
147
+ }
148
+ return widgets;
149
+ }
150
+
151
+ Future<void> _confirmDelete(
152
+ BuildContext context, {
153
+ required String title,
154
+ required String message,
155
+ required Future<void> Function() onConfirm,
156
+ String confirmLabel = 'Delete',
157
+ }) async {
158
+ final confirmed = await showDialog<bool>(
159
+ context: context,
160
+ builder: (context) {
161
+ return AlertDialog(
162
+ backgroundColor: _bgCard,
163
+ title: Text(title),
164
+ content: Text(message),
165
+ actions: <Widget>[
166
+ TextButton(
167
+ onPressed: () => Navigator.of(context).pop(false),
168
+ child: Text('Cancel'),
169
+ ),
170
+ FilledButton(
171
+ onPressed: () => Navigator.of(context).pop(true),
172
+ child: Text(confirmLabel),
173
+ ),
174
+ ],
175
+ );
176
+ },
177
+ );
178
+ if (confirmed == true) {
179
+ await onConfirm();
180
+ }
181
+ }
182
+
183
+ Widget _buildHealthSummaryPills(Map<String, dynamic> summary) {
184
+ return Wrap(
185
+ spacing: 10,
186
+ runSpacing: 10,
187
+ children: <Widget>[
188
+ _MetaPill(
189
+ icon: Icons.directions_walk_outlined,
190
+ label: 'Steps ${_asInt(summary['stepsTotal'])}',
191
+ ),
192
+ _MetaPill(
193
+ icon: Icons.favorite_outline,
194
+ label: 'Heart ${_asInt(summary['heartRateRecordCount'])} records',
195
+ ),
196
+ _MetaPill(
197
+ icon: Icons.bedtime_outlined,
198
+ label: 'Sleep ${_asInt(summary['sleepSessionCount'])} sessions',
199
+ ),
200
+ _MetaPill(
201
+ icon: Icons.fitness_center_outlined,
202
+ label: 'Exercise ${_asInt(summary['exerciseSessionCount'])} sessions',
203
+ ),
204
+ _MetaPill(
205
+ icon: Icons.monitor_weight_outlined,
206
+ label: 'Weight ${_asInt(summary['weightRecordCount'])} records',
207
+ ),
208
+ ],
209
+ );
210
+ }
211
+
212
+ class _PageTitle extends StatelessWidget {
213
+ const _PageTitle({
214
+ required this.title,
215
+ required this.subtitle,
216
+ this.trailing,
217
+ });
218
+
219
+ final String title;
220
+ final String subtitle;
221
+ final Widget? trailing;
222
+
223
+ @override
224
+ Widget build(BuildContext context) {
225
+ final compact = MediaQuery.sizeOf(context).width < 760;
226
+ return Padding(
227
+ padding: const EdgeInsets.only(bottom: 24),
228
+ child: compact
229
+ ? Column(
230
+ crossAxisAlignment: CrossAxisAlignment.start,
231
+ children: <Widget>[
232
+ Text('CONTROL SURFACE', style: _sectionEyebrowStyle()),
233
+ const SizedBox(height: 8),
234
+ Text(title, style: _displayTitleStyle(30)),
235
+ const SizedBox(height: 10),
236
+ ConstrainedBox(
237
+ constraints: const BoxConstraints(maxWidth: 720),
238
+ child: Text(
239
+ subtitle,
240
+ style: TextStyle(color: _textSecondary, height: 1.5),
241
+ ),
242
+ ),
243
+ if (trailing != null) ...<Widget>[
244
+ const SizedBox(height: 18),
245
+ trailing!,
246
+ ],
247
+ ],
248
+ )
249
+ : Row(
250
+ crossAxisAlignment: CrossAxisAlignment.start,
251
+ children: <Widget>[
252
+ Expanded(
253
+ child: Column(
254
+ crossAxisAlignment: CrossAxisAlignment.start,
255
+ children: <Widget>[
256
+ Text('CONTROL SURFACE', style: _sectionEyebrowStyle()),
257
+ const SizedBox(height: 8),
258
+ Text(title, style: _displayTitleStyle(32)),
259
+ const SizedBox(height: 10),
260
+ ConstrainedBox(
261
+ constraints: const BoxConstraints(maxWidth: 760),
262
+ child: Text(
263
+ subtitle,
264
+ style: TextStyle(color: _textSecondary, height: 1.5),
265
+ ),
266
+ ),
267
+ ],
268
+ ),
269
+ ),
270
+ if (trailing != null) trailing!,
271
+ ],
272
+ ),
273
+ );
274
+ }
275
+ }
276
+
277
+ class _RunStatusPanel extends StatelessWidget {
278
+ const _RunStatusPanel({required this.run, required this.tools});
279
+
280
+ final ActiveRunState? run;
281
+ final List<ToolEventItem> tools;
282
+
283
+ @override
284
+ Widget build(BuildContext context) {
285
+ final runningCount = tools.where((tool) => tool.status == 'running').length;
286
+ final helperCount = tools.where((tool) => tool.isHelperRelated).length;
287
+ final webCount = tools.where((tool) => tool.isWebRelated).length;
288
+
289
+ return Card(
290
+ child: Padding(
291
+ padding: const EdgeInsets.all(18),
292
+ child: Column(
293
+ crossAxisAlignment: CrossAxisAlignment.start,
294
+ children: <Widget>[
295
+ Row(
296
+ children: <Widget>[
297
+ Expanded(
298
+ child: Column(
299
+ crossAxisAlignment: CrossAxisAlignment.start,
300
+ children: <Widget>[
301
+ Text(
302
+ run?.title ?? 'Live run',
303
+ style: TextStyle(
304
+ fontSize: 16,
305
+ fontWeight: FontWeight.w700,
306
+ ),
307
+ ),
308
+ const SizedBox(height: 6),
309
+ Text(
310
+ run == null
311
+ ? 'Waiting for run events...'
312
+ : [
313
+ '${run!.phase}${run!.iteration > 0 ? ' · step ${run!.iteration}' : ''}',
314
+ if (run!.pendingSteeringCount > 0)
315
+ '${run!.pendingSteeringCount} steering ${run!.pendingSteeringCount == 1 ? 'update' : 'updates'} queued',
316
+ ].join(' · '),
317
+ style: TextStyle(color: _textSecondary),
318
+ ),
319
+ ],
320
+ ),
321
+ ),
322
+ if (run != null && run!.model.isNotEmpty)
323
+ _MetaPill(label: run!.model, icon: Icons.memory_outlined),
324
+ ],
325
+ ),
326
+ if (tools.isNotEmpty) ...<Widget>[
327
+ const SizedBox(height: 14),
328
+ Wrap(
329
+ spacing: 10,
330
+ runSpacing: 10,
331
+ children: <Widget>[
332
+ _MetaPill(
333
+ label: '${tools.length} events',
334
+ icon: Icons.timeline_outlined,
335
+ ),
336
+ if (runningCount > 0)
337
+ _MetaPill(
338
+ label: '$runningCount active',
339
+ icon: Icons.sync_outlined,
340
+ color: _warning,
341
+ ),
342
+ if (webCount > 0)
343
+ _MetaPill(
344
+ label: '$webCount web',
345
+ icon: Icons.language_outlined,
346
+ ),
347
+ if (helperCount > 0)
348
+ _MetaPill(
349
+ label: '$helperCount helpers',
350
+ icon: Icons.account_tree_outlined,
351
+ ),
352
+ ],
353
+ ),
354
+ const SizedBox(height: 14),
355
+ ...tools.asMap().entries.map(
356
+ (entry) => Padding(
357
+ padding: EdgeInsets.only(
358
+ bottom: entry.key == tools.length - 1 ? 0 : 12,
359
+ ),
360
+ child: _ToolEventTimelineRow(
361
+ tool: entry.value,
362
+ isLast: entry.key == tools.length - 1,
363
+ ),
364
+ ),
365
+ ),
366
+ ] else
367
+ Text(
368
+ 'Waiting for task events...',
369
+ style: TextStyle(color: _textSecondary),
370
+ ),
371
+ ],
372
+ ),
373
+ ),
374
+ );
375
+ }
376
+ }
377
+
378
+ class _ToolEventTimelineRow extends StatelessWidget {
379
+ const _ToolEventTimelineRow({required this.tool, required this.isLast});
380
+
381
+ final ToolEventItem tool;
382
+ final bool isLast;
383
+
384
+ @override
385
+ Widget build(BuildContext context) {
386
+ Color color;
387
+ switch (tool.status) {
388
+ case 'running':
389
+ color = _warning;
390
+ break;
391
+ case 'failed':
392
+ color = _danger;
393
+ break;
394
+ default:
395
+ color = _success;
396
+ }
397
+ return Row(
398
+ crossAxisAlignment: CrossAxisAlignment.start,
399
+ children: <Widget>[
400
+ SizedBox(
401
+ width: 28,
402
+ child: Column(
403
+ children: <Widget>[
404
+ Container(
405
+ width: 28,
406
+ height: 28,
407
+ decoration: BoxDecoration(
408
+ color: color.withValues(alpha: 0.14),
409
+ shape: BoxShape.circle,
410
+ ),
411
+ child: Icon(tool.laneIcon, size: 16, color: color),
412
+ ),
413
+ if (!isLast)
414
+ Container(
415
+ width: 2,
416
+ height: 62,
417
+ margin: const EdgeInsets.only(top: 6),
418
+ decoration: BoxDecoration(
419
+ color: _border,
420
+ borderRadius: BorderRadius.circular(999),
421
+ ),
422
+ ),
423
+ ],
424
+ ),
425
+ ),
426
+ const SizedBox(width: 12),
427
+ Expanded(
428
+ child: Container(
429
+ padding: const EdgeInsets.all(12),
430
+ decoration: BoxDecoration(
431
+ color: _bgSecondary,
432
+ borderRadius: BorderRadius.circular(14),
433
+ border: Border.all(color: _border),
434
+ ),
435
+ child: Column(
436
+ crossAxisAlignment: CrossAxisAlignment.start,
437
+ children: <Widget>[
438
+ Row(
439
+ crossAxisAlignment: CrossAxisAlignment.start,
440
+ children: <Widget>[
441
+ Expanded(
442
+ child: Column(
443
+ crossAxisAlignment: CrossAxisAlignment.start,
444
+ children: <Widget>[
445
+ Text(
446
+ tool.toolName,
447
+ style: TextStyle(fontWeight: FontWeight.w700),
448
+ ),
449
+ const SizedBox(height: 4),
450
+ Text(
451
+ tool.laneLabel,
452
+ style: TextStyle(
453
+ color: _textSecondary,
454
+ fontSize: 12,
455
+ fontWeight: FontWeight.w600,
456
+ ),
457
+ ),
458
+ ],
459
+ ),
460
+ ),
461
+ _StatusPill(label: tool.statusLabel, color: color),
462
+ ],
463
+ ),
464
+ if (tool.summary.isNotEmpty) ...<Widget>[
465
+ const SizedBox(height: 8),
466
+ Text(
467
+ tool.compactSummary,
468
+ style: TextStyle(color: _textSecondary, height: 1.45),
469
+ ),
470
+ ],
471
+ ],
472
+ ),
473
+ ),
474
+ ),
475
+ ],
476
+ );
477
+ }
478
+ }
479
+
480
+ class _SettingToggle extends StatelessWidget {
481
+ const _SettingToggle({
482
+ required this.title,
483
+ required this.subtitle,
484
+ required this.value,
485
+ required this.onChanged,
486
+ });
487
+
488
+ final String title;
489
+ final String subtitle;
490
+ final bool value;
491
+ final ValueChanged<bool> onChanged;
492
+
493
+ @override
494
+ Widget build(BuildContext context) {
495
+ return SwitchListTile(
496
+ value: value,
497
+ contentPadding: EdgeInsets.zero,
498
+ title: Text(title),
499
+ subtitle: Text(subtitle),
500
+ onChanged: onChanged,
501
+ );
502
+ }
503
+ }
504
+
505
+ class _OverviewCard extends StatelessWidget {
506
+ const _OverviewCard({
507
+ required this.title,
508
+ required this.value,
509
+ required this.helper,
510
+ });
511
+
512
+ final String title;
513
+ final String value;
514
+ final String helper;
515
+
516
+ @override
517
+ Widget build(BuildContext context) {
518
+ return Card(
519
+ child: Padding(
520
+ padding: const EdgeInsets.all(22),
521
+ child: Column(
522
+ crossAxisAlignment: CrossAxisAlignment.start,
523
+ children: <Widget>[
524
+ Container(
525
+ width: 34,
526
+ height: 4,
527
+ decoration: BoxDecoration(
528
+ color: _accent,
529
+ borderRadius: BorderRadius.circular(999),
530
+ ),
531
+ ),
532
+ const SizedBox(height: 16),
533
+ Text(
534
+ title.toUpperCase(),
535
+ style: TextStyle(
536
+ color: _textSecondary,
537
+ fontSize: 11,
538
+ fontWeight: FontWeight.w700,
539
+ letterSpacing: 1.2,
540
+ ),
541
+ ),
542
+ const SizedBox(height: 10),
543
+ Text(value, style: _displayTitleStyle(28)),
544
+ const SizedBox(height: 12),
545
+ Text(helper, style: TextStyle(color: _textSecondary, height: 1.45)),
546
+ ],
547
+ ),
548
+ ),
549
+ );
550
+ }
551
+ }
552
+
553
+ class _EmptyCard extends StatelessWidget {
554
+ const _EmptyCard({required this.title, required this.subtitle});
555
+
556
+ final String title;
557
+ final String subtitle;
558
+
559
+ @override
560
+ Widget build(BuildContext context) {
561
+ return Card(
562
+ child: Padding(
563
+ padding: const EdgeInsets.all(34),
564
+ child: _EmptyState(title: title, subtitle: subtitle),
565
+ ),
566
+ );
567
+ }
568
+ }
569
+
570
+ class _SectionTitle extends StatelessWidget {
571
+ const _SectionTitle(this.text);
572
+
573
+ final String text;
574
+
575
+ @override
576
+ Widget build(BuildContext context) {
577
+ return Text(
578
+ text.toUpperCase(),
579
+ style: TextStyle(
580
+ fontSize: 12,
581
+ fontWeight: FontWeight.w800,
582
+ letterSpacing: 1.1,
583
+ color: _textSecondary,
584
+ ),
585
+ );
586
+ }
587
+ }
588
+
589
+ class _DotStatus extends StatelessWidget {
590
+ const _DotStatus({required this.label, required this.color});
591
+
592
+ final String label;
593
+ final Color color;
594
+
595
+ @override
596
+ Widget build(BuildContext context) {
597
+ return Container(
598
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
599
+ decoration: BoxDecoration(
600
+ color: _bgSecondary,
601
+ borderRadius: BorderRadius.circular(999),
602
+ border: Border.all(color: _border),
603
+ ),
604
+ child: Row(
605
+ mainAxisSize: MainAxisSize.min,
606
+ children: <Widget>[
607
+ Container(
608
+ width: 8,
609
+ height: 8,
610
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
611
+ ),
612
+ const SizedBox(width: 8),
613
+ Text(label),
614
+ ],
615
+ ),
616
+ );
617
+ }
618
+ }
619
+
620
+ class _SidebarButton extends StatelessWidget {
621
+ const _SidebarButton({
622
+ required this.label,
623
+ required this.icon,
624
+ this.active = false,
625
+ this.indent = 0,
626
+ this.iconSize = 18,
627
+ this.fontSize = 13,
628
+ this.trailing,
629
+ required this.onTap,
630
+ });
631
+
632
+ final String label;
633
+ final IconData icon;
634
+ final bool active;
635
+ final double indent;
636
+ final double iconSize;
637
+ final double fontSize;
638
+ final Widget? trailing;
639
+ final VoidCallback? onTap;
640
+
641
+ @override
642
+ Widget build(BuildContext context) {
643
+ return Padding(
644
+ padding: const EdgeInsets.only(bottom: 6),
645
+ child: AnimatedContainer(
646
+ duration: const Duration(milliseconds: 180),
647
+ curve: Curves.easeOutCubic,
648
+ decoration: BoxDecoration(
649
+ gradient: active
650
+ ? LinearGradient(
651
+ colors: <Color>[
652
+ _accentMuted,
653
+ _accentMuted.withValues(alpha: 0.06),
654
+ ],
655
+ )
656
+ : null,
657
+ borderRadius: BorderRadius.circular(18),
658
+ border: Border.all(
659
+ color: active
660
+ ? _accent.withValues(alpha: 0.35)
661
+ : Colors.transparent,
662
+ ),
663
+ ),
664
+ child: Material(
665
+ color: Colors.transparent,
666
+ child: InkWell(
667
+ borderRadius: BorderRadius.circular(18),
668
+ onTap: onTap,
669
+ child: Container(
670
+ width: double.infinity,
671
+ padding: EdgeInsets.fromLTRB(12 + indent, 12, 12, 12),
672
+ child: Row(
673
+ children: <Widget>[
674
+ if (active)
675
+ Container(
676
+ width: 6,
677
+ height: 26,
678
+ margin: const EdgeInsets.only(right: 10),
679
+ decoration: BoxDecoration(
680
+ color: _accent,
681
+ borderRadius: BorderRadius.circular(999),
682
+ ),
683
+ ),
684
+ Icon(
685
+ icon,
686
+ size: iconSize,
687
+ color: active ? _accentHover : _textSecondary,
688
+ ),
689
+ const SizedBox(width: 10),
690
+ Expanded(
691
+ child: Text(
692
+ label,
693
+ style: TextStyle(
694
+ fontSize: fontSize,
695
+ fontWeight: active ? FontWeight.w700 : FontWeight.w600,
696
+ color: active ? _textPrimary : _textSecondary,
697
+ ),
698
+ ),
699
+ ),
700
+ if (trailing != null) ...<Widget>[
701
+ const SizedBox(width: 8),
702
+ trailing!,
703
+ ],
704
+ ],
705
+ ),
706
+ ),
707
+ ),
708
+ ),
709
+ ),
710
+ );
711
+ }
712
+ }
713
+
714
+ class _SidebarIconButton extends StatelessWidget {
715
+ const _SidebarIconButton({
716
+ required this.tooltip,
717
+ required this.icon,
718
+ required this.onTap,
719
+ });
720
+
721
+ final String tooltip;
722
+ final IconData icon;
723
+ final VoidCallback? onTap;
724
+
725
+ @override
726
+ Widget build(BuildContext context) {
727
+ return Tooltip(
728
+ message: tooltip,
729
+ child: Material(
730
+ color: _bgCard.withValues(alpha: 0.8),
731
+ shape: CircleBorder(side: BorderSide(color: _borderLight)),
732
+ child: InkWell(
733
+ customBorder: CircleBorder(),
734
+ onTap: onTap,
735
+ child: SizedBox(
736
+ width: 38,
737
+ height: 38,
738
+ child: Icon(icon, size: 17, color: _textSecondary),
739
+ ),
740
+ ),
741
+ ),
742
+ );
743
+ }
744
+ }
745
+
746
+ class _BlurOrb extends StatelessWidget {
747
+ const _BlurOrb({required this.size, required this.color});
748
+
749
+ final double size;
750
+ final Color color;
751
+
752
+ @override
753
+ Widget build(BuildContext context) {
754
+ return IgnorePointer(
755
+ child: Container(
756
+ width: size,
757
+ height: size,
758
+ decoration: BoxDecoration(
759
+ shape: BoxShape.circle,
760
+ boxShadow: <BoxShadow>[
761
+ BoxShadow(
762
+ color: color.withValues(alpha: 0.18),
763
+ blurRadius: 120,
764
+ spreadRadius: 30,
765
+ ),
766
+ ],
767
+ ),
768
+ ),
769
+ );
770
+ }
771
+ }
772
+
773
+ class _LogoBadge extends StatelessWidget {
774
+ const _LogoBadge({required this.size});
775
+
776
+ final double size;
777
+
778
+ @override
779
+ Widget build(BuildContext context) {
780
+ return Container(
781
+ width: size,
782
+ height: size,
783
+ decoration: BoxDecoration(
784
+ gradient: LinearGradient(
785
+ colors: <Color>[_brandAccent, _brandAccentAlt],
786
+ begin: Alignment.topLeft,
787
+ end: Alignment.bottomRight,
788
+ ),
789
+ borderRadius: BorderRadius.circular(size * 0.34),
790
+ boxShadow: <BoxShadow>[
791
+ BoxShadow(
792
+ color: _brandAccent.withValues(alpha: 0.32),
793
+ blurRadius: 36,
794
+ offset: const Offset(0, 10),
795
+ ),
796
+ ],
797
+ border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
798
+ ),
799
+ child: Padding(
800
+ padding: EdgeInsets.all(size * 0.18),
801
+ child: CustomPaint(painter: _NeoAgentLogoPainter()),
802
+ ),
803
+ );
804
+ }
805
+ }
806
+
807
+ class _BrandLockup extends StatelessWidget {
808
+ const _BrandLockup({
809
+ required this.logoSize,
810
+ this.titleFontSize = 28,
811
+ this.direction = Axis.vertical,
812
+ this.spacing = 18,
813
+ this.alignment = CrossAxisAlignment.center,
814
+ });
815
+
816
+ final double logoSize;
817
+ final double titleFontSize;
818
+ final Axis direction;
819
+ final double spacing;
820
+ final CrossAxisAlignment alignment;
821
+
822
+ @override
823
+ Widget build(BuildContext context) {
824
+ final title = Text(
825
+ 'NeoOS',
826
+ style: GoogleFonts.spaceGrotesk(
827
+ fontSize: titleFontSize,
828
+ fontWeight: FontWeight.w700,
829
+ color: _textPrimary,
830
+ letterSpacing: -0.4,
831
+ ),
832
+ );
833
+
834
+ if (direction == Axis.horizontal) {
835
+ return Row(
836
+ mainAxisSize: MainAxisSize.min,
837
+ children: <Widget>[
838
+ _LogoBadge(size: logoSize),
839
+ SizedBox(width: spacing),
840
+ Flexible(child: title),
841
+ ],
842
+ );
843
+ }
844
+
845
+ return Column(
846
+ mainAxisSize: MainAxisSize.min,
847
+ crossAxisAlignment: alignment,
848
+ children: <Widget>[
849
+ _LogoBadge(size: logoSize),
850
+ SizedBox(height: spacing),
851
+ title,
852
+ ],
853
+ );
854
+ }
855
+ }
856
+
857
+ class _NeoAgentLogoPainter extends CustomPainter {
858
+ @override
859
+ void paint(Canvas canvas, Size size) {
860
+ final fillPaint = Paint()
861
+ ..color = Colors.white
862
+ ..style = PaintingStyle.fill;
863
+ final strokePaint = Paint()
864
+ ..color = Colors.white
865
+ ..style = PaintingStyle.stroke
866
+ ..strokeWidth = size.width * 0.08
867
+ ..strokeCap = StrokeCap.round
868
+ ..strokeJoin = StrokeJoin.round;
869
+
870
+ final top = Path()
871
+ ..moveTo(size.width * 0.5, size.height * 0.08)
872
+ ..lineTo(size.width * 0.1, size.height * 0.3)
873
+ ..lineTo(size.width * 0.5, size.height * 0.52)
874
+ ..lineTo(size.width * 0.9, size.height * 0.3)
875
+ ..close();
876
+ canvas.drawPath(top, fillPaint);
877
+
878
+ final middle = Path()
879
+ ..moveTo(size.width * 0.1, size.height * 0.52)
880
+ ..lineTo(size.width * 0.5, size.height * 0.74)
881
+ ..lineTo(size.width * 0.9, size.height * 0.52);
882
+ canvas.drawPath(middle, strokePaint);
883
+
884
+ final bottom = Path()
885
+ ..moveTo(size.width * 0.1, size.height * 0.72)
886
+ ..lineTo(size.width * 0.5, size.height * 0.94)
887
+ ..lineTo(size.width * 0.9, size.height * 0.72);
888
+ canvas.drawPath(bottom, strokePaint);
889
+ }
890
+
891
+ @override
892
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
893
+ }
894
+
895
+ class _EmptyState extends StatelessWidget {
896
+ const _EmptyState({required this.title, required this.subtitle});
897
+
898
+ final String title;
899
+ final String subtitle;
900
+
901
+ @override
902
+ Widget build(BuildContext context) {
903
+ return Column(
904
+ mainAxisSize: MainAxisSize.min,
905
+ children: <Widget>[
906
+ const _LogoBadge(size: 52),
907
+ const SizedBox(height: 12),
908
+ Text(
909
+ title,
910
+ style: TextStyle(
911
+ fontSize: 17,
912
+ fontWeight: FontWeight.w600,
913
+ color: _textPrimary,
914
+ ),
915
+ ),
916
+ const SizedBox(height: 8),
917
+ ConstrainedBox(
918
+ constraints: const BoxConstraints(maxWidth: 360),
919
+ child: Text(
920
+ subtitle,
921
+ textAlign: TextAlign.center,
922
+ style: TextStyle(fontSize: 13, color: _textMuted),
923
+ ),
924
+ ),
925
+ ],
926
+ );
927
+ }
928
+ }
929
+
930
+ class _ChatBubble extends StatelessWidget {
931
+ const _ChatBubble({required this.entry, this.onLoadRunDetail});
932
+
933
+ final ChatEntry entry;
934
+ final Future<RunDetailSnapshot> Function(String runId)? onLoadRunDetail;
935
+
936
+ @override
937
+ Widget build(BuildContext context) {
938
+ final isUser = entry.role == 'user';
939
+ final isTransient = entry.transient;
940
+
941
+ return Opacity(
942
+ opacity: isTransient ? 0.92 : 1,
943
+ child: Row(
944
+ crossAxisAlignment: CrossAxisAlignment.start,
945
+ mainAxisAlignment: isUser
946
+ ? MainAxisAlignment.end
947
+ : MainAxisAlignment.start,
948
+ children: <Widget>[
949
+ if (!isUser) ...<Widget>[
950
+ const _MessageAvatar(assistant: true),
951
+ const SizedBox(width: 12),
952
+ ],
953
+ Flexible(
954
+ child: Container(
955
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
956
+ decoration: BoxDecoration(
957
+ color: isUser ? _accent : _bgCard,
958
+ borderRadius: BorderRadius.only(
959
+ topLeft: const Radius.circular(14),
960
+ topRight: const Radius.circular(14),
961
+ bottomLeft: Radius.circular(isUser ? 14 : 4),
962
+ bottomRight: Radius.circular(isUser ? 4 : 14),
963
+ ),
964
+ border: isUser ? null : Border.all(color: _border),
965
+ boxShadow: isUser
966
+ ? const <BoxShadow>[
967
+ BoxShadow(
968
+ color: Color(0x4D14B8A6),
969
+ blurRadius: 12,
970
+ offset: Offset(0, 2),
971
+ ),
972
+ ]
973
+ : null,
974
+ ),
975
+ child: Column(
976
+ crossAxisAlignment: isUser
977
+ ? CrossAxisAlignment.end
978
+ : CrossAxisAlignment.start,
979
+ children: <Widget>[
980
+ if (!isUser && entry.platformTag != null)
981
+ Padding(
982
+ padding: const EdgeInsets.only(bottom: 8),
983
+ child: _StatusPill(
984
+ label: entry.platformTag!,
985
+ color: entry.platform == 'live' ? _info : _warning,
986
+ ),
987
+ ),
988
+ MarkdownBody(
989
+ data: entry.content,
990
+ selectable: true,
991
+ styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context))
992
+ .copyWith(
993
+ p: Theme.of(context).textTheme.bodyMedium?.copyWith(
994
+ color: isUser ? Colors.white : _textPrimary,
995
+ height: 1.65,
996
+ ),
997
+ code: Theme.of(context).textTheme.bodyMedium
998
+ ?.copyWith(
999
+ fontFamily:
1000
+ GoogleFonts.jetBrainsMono().fontFamily,
1001
+ backgroundColor: _bgPrimary,
1002
+ color: isUser ? Colors.white : _textPrimary,
1003
+ ),
1004
+ blockquoteDecoration: BoxDecoration(
1005
+ borderRadius: BorderRadius.circular(14),
1006
+ color: const Color(0x22000000),
1007
+ ),
1008
+ ),
1009
+ ),
1010
+ if (!isUser &&
1011
+ entry.runId?.trim().isNotEmpty == true) ...<Widget>[
1012
+ const SizedBox(height: 12),
1013
+ _MessageRunPreview(
1014
+ runId: entry.runId!.trim(),
1015
+ onLoadRunDetail: onLoadRunDetail,
1016
+ ),
1017
+ ],
1018
+ const SizedBox(height: 10),
1019
+ Text(
1020
+ entry.createdAtLabel,
1021
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1022
+ color: isUser ? const Color(0xCCFFFFFF) : _textSecondary,
1023
+ ),
1024
+ ),
1025
+ ],
1026
+ ),
1027
+ ),
1028
+ ),
1029
+ if (isUser) ...<Widget>[
1030
+ const SizedBox(width: 12),
1031
+ const _MessageAvatar(assistant: false),
1032
+ ],
1033
+ ],
1034
+ ),
1035
+ );
1036
+ }
1037
+ }
1038
+
1039
+ class _MessageRunPreview extends StatefulWidget {
1040
+ const _MessageRunPreview({
1041
+ required this.runId,
1042
+ required this.onLoadRunDetail,
1043
+ });
1044
+
1045
+ final String runId;
1046
+ final Future<RunDetailSnapshot> Function(String runId)? onLoadRunDetail;
1047
+
1048
+ @override
1049
+ State<_MessageRunPreview> createState() => _MessageRunPreviewState();
1050
+ }
1051
+
1052
+ class _MessageRunPreviewState extends State<_MessageRunPreview> {
1053
+ late Future<RunDetailSnapshot>? _future;
1054
+
1055
+ @override
1056
+ void initState() {
1057
+ super.initState();
1058
+ _future = widget.onLoadRunDetail?.call(widget.runId);
1059
+ }
1060
+
1061
+ @override
1062
+ void didUpdateWidget(covariant _MessageRunPreview oldWidget) {
1063
+ super.didUpdateWidget(oldWidget);
1064
+ if (oldWidget.runId != widget.runId ||
1065
+ oldWidget.onLoadRunDetail != widget.onLoadRunDetail) {
1066
+ _future = widget.onLoadRunDetail?.call(widget.runId);
1067
+ }
1068
+ }
1069
+
1070
+ @override
1071
+ Widget build(BuildContext context) {
1072
+ if (_future == null) {
1073
+ return const SizedBox.shrink();
1074
+ }
1075
+ return FutureBuilder<RunDetailSnapshot>(
1076
+ future: _future,
1077
+ builder: (context, snapshot) {
1078
+ if (snapshot.connectionState == ConnectionState.waiting) {
1079
+ return _MessageRunCardShell(
1080
+ child: Row(
1081
+ children: <Widget>[
1082
+ SizedBox.square(
1083
+ dimension: 14,
1084
+ child: CircularProgressIndicator(strokeWidth: 2),
1085
+ ),
1086
+ const SizedBox(width: 10),
1087
+ Expanded(
1088
+ child: Text(
1089
+ 'Loading execution details...',
1090
+ style: TextStyle(color: _textSecondary, fontSize: 12),
1091
+ ),
1092
+ ),
1093
+ ],
1094
+ ),
1095
+ );
1096
+ }
1097
+ if (snapshot.hasError || !snapshot.hasData) {
1098
+ return const SizedBox.shrink();
1099
+ }
1100
+ final detail = snapshot.data!;
1101
+ final previewSteps = detail.steps.take(4).toList();
1102
+ return _MessageRunCardShell(
1103
+ child: Column(
1104
+ crossAxisAlignment: CrossAxisAlignment.start,
1105
+ children: <Widget>[
1106
+ Row(
1107
+ children: <Widget>[
1108
+ Expanded(
1109
+ child: Text(
1110
+ detail.run.title.ifEmpty('Execution'),
1111
+ style: TextStyle(
1112
+ fontWeight: FontWeight.w700,
1113
+ color: _textPrimary,
1114
+ ),
1115
+ ),
1116
+ ),
1117
+ _StatusPill(
1118
+ label: detail.run.statusLabel,
1119
+ color: detail.run.statusColor,
1120
+ ),
1121
+ ],
1122
+ ),
1123
+ const SizedBox(height: 10),
1124
+ Wrap(
1125
+ spacing: 8,
1126
+ runSpacing: 8,
1127
+ children: <Widget>[
1128
+ _MetaPill(
1129
+ label: '${detail.steps.length} steps',
1130
+ icon: Icons.timeline_outlined,
1131
+ ),
1132
+ if (detail.webStepCount > 0)
1133
+ _MetaPill(
1134
+ label: '${detail.webStepCount} web',
1135
+ icon: Icons.language_outlined,
1136
+ ),
1137
+ if (detail.helperCount > 0)
1138
+ _MetaPill(
1139
+ label: '${detail.helperCount} helpers',
1140
+ icon: Icons.account_tree_outlined,
1141
+ ),
1142
+ if (detail.planningStepCount > 0)
1143
+ _MetaPill(
1144
+ label: '${detail.planningStepCount} planning',
1145
+ icon: Icons.route_outlined,
1146
+ ),
1147
+ ],
1148
+ ),
1149
+ const SizedBox(height: 12),
1150
+ ...previewSteps.asMap().entries.map(
1151
+ (entry) => Padding(
1152
+ padding: EdgeInsets.only(
1153
+ bottom: entry.key == previewSteps.length - 1 ? 0 : 10,
1154
+ ),
1155
+ child: _MessageRunStepRow(
1156
+ step: entry.value,
1157
+ isLast: entry.key == previewSteps.length - 1,
1158
+ ),
1159
+ ),
1160
+ ),
1161
+ if (detail.steps.length > previewSteps.length) ...<Widget>[
1162
+ const SizedBox(height: 10),
1163
+ Text(
1164
+ '${detail.steps.length - previewSteps.length} more steps in run history',
1165
+ style: TextStyle(
1166
+ color: _textSecondary,
1167
+ fontSize: 12,
1168
+ fontWeight: FontWeight.w600,
1169
+ ),
1170
+ ),
1171
+ ],
1172
+ ],
1173
+ ),
1174
+ );
1175
+ },
1176
+ );
1177
+ }
1178
+ }
1179
+
1180
+ class _MessageRunCardShell extends StatelessWidget {
1181
+ const _MessageRunCardShell({required this.child});
1182
+
1183
+ final Widget child;
1184
+
1185
+ @override
1186
+ Widget build(BuildContext context) {
1187
+ return Container(
1188
+ width: double.infinity,
1189
+ padding: const EdgeInsets.all(12),
1190
+ decoration: BoxDecoration(
1191
+ color: _bgPrimary,
1192
+ borderRadius: BorderRadius.circular(14),
1193
+ border: Border.all(color: _border),
1194
+ ),
1195
+ child: child,
1196
+ );
1197
+ }
1198
+ }
1199
+
1200
+ class _MessageRunStepRow extends StatelessWidget {
1201
+ const _MessageRunStepRow({required this.step, required this.isLast});
1202
+
1203
+ final RunStepItem step;
1204
+ final bool isLast;
1205
+
1206
+ @override
1207
+ Widget build(BuildContext context) {
1208
+ return Row(
1209
+ crossAxisAlignment: CrossAxisAlignment.start,
1210
+ children: <Widget>[
1211
+ SizedBox(
1212
+ width: 24,
1213
+ child: Column(
1214
+ children: <Widget>[
1215
+ Container(
1216
+ width: 24,
1217
+ height: 24,
1218
+ decoration: BoxDecoration(
1219
+ color: step.statusColor.withValues(alpha: 0.14),
1220
+ shape: BoxShape.circle,
1221
+ ),
1222
+ child: Icon(step.laneIcon, size: 14, color: step.statusColor),
1223
+ ),
1224
+ if (!isLast)
1225
+ Container(
1226
+ width: 2,
1227
+ height: 34,
1228
+ margin: const EdgeInsets.only(top: 6),
1229
+ decoration: BoxDecoration(
1230
+ color: _border,
1231
+ borderRadius: BorderRadius.circular(999),
1232
+ ),
1233
+ ),
1234
+ ],
1235
+ ),
1236
+ ),
1237
+ const SizedBox(width: 10),
1238
+ Expanded(
1239
+ child: Column(
1240
+ crossAxisAlignment: CrossAxisAlignment.start,
1241
+ children: <Widget>[
1242
+ Row(
1243
+ children: <Widget>[
1244
+ Expanded(
1245
+ child: Text(
1246
+ step.label,
1247
+ style: TextStyle(
1248
+ fontWeight: FontWeight.w600,
1249
+ color: _textPrimary,
1250
+ ),
1251
+ ),
1252
+ ),
1253
+ Text(
1254
+ step.laneLabel,
1255
+ style: TextStyle(
1256
+ color: _textSecondary,
1257
+ fontSize: 11,
1258
+ fontWeight: FontWeight.w700,
1259
+ ),
1260
+ ),
1261
+ ],
1262
+ ),
1263
+ const SizedBox(height: 3),
1264
+ Text(
1265
+ step.compactSummary,
1266
+ style: TextStyle(
1267
+ color: _textSecondary,
1268
+ fontSize: 12,
1269
+ height: 1.4,
1270
+ ),
1271
+ ),
1272
+ ],
1273
+ ),
1274
+ ),
1275
+ ],
1276
+ );
1277
+ }
1278
+ }
1279
+
1280
+ class _MessageAvatar extends StatelessWidget {
1281
+ const _MessageAvatar({required this.assistant});
1282
+
1283
+ final bool assistant;
1284
+
1285
+ @override
1286
+ Widget build(BuildContext context) {
1287
+ return Container(
1288
+ width: 30,
1289
+ height: 30,
1290
+ decoration: BoxDecoration(
1291
+ borderRadius: BorderRadius.circular(8),
1292
+ gradient: assistant
1293
+ ? LinearGradient(colors: <Color>[_accent, _accentAlt])
1294
+ : null,
1295
+ color: assistant ? null : _bgTertiary,
1296
+ boxShadow: assistant
1297
+ ? const <BoxShadow>[
1298
+ BoxShadow(
1299
+ color: Color(0x5914B8A6),
1300
+ blurRadius: 10,
1301
+ offset: Offset(0, 2),
1302
+ ),
1303
+ ]
1304
+ : null,
1305
+ ),
1306
+ child: Icon(
1307
+ assistant ? Icons.auto_awesome : Icons.person,
1308
+ size: 16,
1309
+ color: assistant ? Colors.white : _textSecondary,
1310
+ ),
1311
+ );
1312
+ }
1313
+ }
1314
+
1315
+ class _StatusPill extends StatelessWidget {
1316
+ const _StatusPill({required this.label, required this.color});
1317
+
1318
+ final String label;
1319
+ final Color color;
1320
+
1321
+ @override
1322
+ Widget build(BuildContext context) {
1323
+ return Container(
1324
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
1325
+ decoration: BoxDecoration(
1326
+ borderRadius: BorderRadius.circular(999),
1327
+ color: color.withValues(alpha: 0.12),
1328
+ ),
1329
+ child: Text(
1330
+ label,
1331
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
1332
+ color: color,
1333
+ fontWeight: FontWeight.w700,
1334
+ ),
1335
+ ),
1336
+ );
1337
+ }
1338
+ }
1339
+
1340
+ class _MetaPill extends StatelessWidget {
1341
+ const _MetaPill({required this.label, required this.icon, this.color});
1342
+
1343
+ final String label;
1344
+ final IconData icon;
1345
+ final Color? color;
1346
+
1347
+ @override
1348
+ Widget build(BuildContext context) {
1349
+ final accentColor = color ?? const Color(0xFF5EEAD4);
1350
+ return Container(
1351
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1352
+ decoration: BoxDecoration(
1353
+ color: _bgSecondary,
1354
+ borderRadius: BorderRadius.circular(999),
1355
+ border: Border.all(color: _border),
1356
+ ),
1357
+ child: Row(
1358
+ mainAxisSize: MainAxisSize.min,
1359
+ children: <Widget>[
1360
+ Icon(icon, size: 14, color: accentColor),
1361
+ const SizedBox(width: 8),
1362
+ Flexible(
1363
+ child: Text(
1364
+ label,
1365
+ overflow: TextOverflow.ellipsis,
1366
+ softWrap: false,
1367
+ ),
1368
+ ),
1369
+ ],
1370
+ ),
1371
+ );
1372
+ }
1373
+ }
1374
+
1375
+ class _InfoChip extends StatelessWidget {
1376
+ const _InfoChip({required this.icon, required this.label});
1377
+
1378
+ final IconData icon;
1379
+ final String label;
1380
+
1381
+ @override
1382
+ Widget build(BuildContext context) {
1383
+ return Container(
1384
+ width: double.infinity,
1385
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1386
+ decoration: BoxDecoration(
1387
+ color: Colors.white.withValues(alpha: 0.06),
1388
+ borderRadius: BorderRadius.circular(16),
1389
+ border: Border.all(color: Colors.white.withValues(alpha: 0.10)),
1390
+ ),
1391
+ child: Row(
1392
+ children: <Widget>[
1393
+ Icon(icon, size: 16, color: Colors.white.withValues(alpha: 0.72)),
1394
+ const SizedBox(width: 8),
1395
+ Expanded(
1396
+ child: Text(
1397
+ label,
1398
+ style: TextStyle(
1399
+ color: Colors.white.withValues(alpha: 0.82),
1400
+ fontSize: 13,
1401
+ ),
1402
+ ),
1403
+ ),
1404
+ ],
1405
+ ),
1406
+ );
1407
+ }
1408
+ }
1409
+
1410
+ class _InlineError extends StatelessWidget {
1411
+ const _InlineError({required this.message});
1412
+
1413
+ final String message;
1414
+
1415
+ @override
1416
+ Widget build(BuildContext context) {
1417
+ return Container(
1418
+ width: double.infinity,
1419
+ padding: const EdgeInsets.all(12),
1420
+ decoration: BoxDecoration(
1421
+ color: const Color(0x19EF4444),
1422
+ borderRadius: BorderRadius.circular(8),
1423
+ border: Border.all(color: const Color(0x4CEF4444)),
1424
+ ),
1425
+ child: Text(message, style: TextStyle(fontSize: 13)),
1426
+ );
1427
+ }
1428
+ }
1429
+
1430
+ class _InlineSuccess extends StatelessWidget {
1431
+ const _InlineSuccess({required this.message});
1432
+
1433
+ final String message;
1434
+
1435
+ @override
1436
+ Widget build(BuildContext context) {
1437
+ return Container(
1438
+ width: double.infinity,
1439
+ padding: const EdgeInsets.all(12),
1440
+ decoration: BoxDecoration(
1441
+ color: _success.withValues(alpha: 0.10),
1442
+ borderRadius: BorderRadius.circular(8),
1443
+ border: Border.all(color: _success.withValues(alpha: 0.28)),
1444
+ ),
1445
+ child: Row(
1446
+ children: <Widget>[
1447
+ Icon(Icons.check_circle_outline, color: _success, size: 18),
1448
+ const SizedBox(width: 8),
1449
+ Expanded(
1450
+ child: Text(
1451
+ message,
1452
+ style: TextStyle(color: _success, fontSize: 13),
1453
+ ),
1454
+ ),
1455
+ ],
1456
+ ),
1457
+ );
1458
+ }
1459
+ }
1460
+
1461
+ class _GlobalNetworkBanner extends StatelessWidget {
1462
+ const _GlobalNetworkBanner({required this.controller});
1463
+
1464
+ final NeoAgentController controller;
1465
+
1466
+ @override
1467
+ Widget build(BuildContext context) {
1468
+ return LayoutBuilder(
1469
+ builder: (context, constraints) {
1470
+ final compact = constraints.maxWidth < 520;
1471
+ return Material(
1472
+ color: Colors.transparent,
1473
+ child: Container(
1474
+ width: double.infinity,
1475
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
1476
+ decoration: BoxDecoration(
1477
+ color: _warning.withValues(alpha: 0.14),
1478
+ borderRadius: BorderRadius.circular(18),
1479
+ border: Border.all(color: _warning.withValues(alpha: 0.32)),
1480
+ boxShadow: <BoxShadow>[
1481
+ BoxShadow(
1482
+ color: Colors.black.withValues(alpha: 0.12),
1483
+ blurRadius: 18,
1484
+ offset: const Offset(0, 10),
1485
+ ),
1486
+ ],
1487
+ ),
1488
+ child: compact
1489
+ ? Column(
1490
+ crossAxisAlignment: CrossAxisAlignment.start,
1491
+ children: <Widget>[
1492
+ Row(
1493
+ children: <Widget>[
1494
+ Icon(
1495
+ Icons.cloud_off_outlined,
1496
+ color: _warning,
1497
+ size: 18,
1498
+ ),
1499
+ const SizedBox(width: 10),
1500
+ Expanded(
1501
+ child: Text(
1502
+ controller.offlineBannerMessage,
1503
+ style: TextStyle(
1504
+ color: _textPrimary,
1505
+ height: 1.35,
1506
+ ),
1507
+ ),
1508
+ ),
1509
+ ],
1510
+ ),
1511
+ const SizedBox(height: 10),
1512
+ OutlinedButton(
1513
+ onPressed: controller.refreshConnectivityStatus,
1514
+ style: OutlinedButton.styleFrom(
1515
+ foregroundColor: _textPrimary,
1516
+ side: BorderSide(
1517
+ color: _warning.withValues(alpha: 0.38),
1518
+ ),
1519
+ ),
1520
+ child: const Text('Retry'),
1521
+ ),
1522
+ ],
1523
+ )
1524
+ : Row(
1525
+ children: <Widget>[
1526
+ Icon(Icons.cloud_off_outlined, color: _warning, size: 18),
1527
+ const SizedBox(width: 10),
1528
+ Expanded(
1529
+ child: Text(
1530
+ controller.offlineBannerMessage,
1531
+ style: TextStyle(color: _textPrimary, height: 1.35),
1532
+ ),
1533
+ ),
1534
+ const SizedBox(width: 12),
1535
+ OutlinedButton(
1536
+ onPressed: controller.refreshConnectivityStatus,
1537
+ style: OutlinedButton.styleFrom(
1538
+ foregroundColor: _textPrimary,
1539
+ side: BorderSide(
1540
+ color: _warning.withValues(alpha: 0.38),
1541
+ ),
1542
+ ),
1543
+ child: const Text('Retry'),
1544
+ ),
1545
+ ],
1546
+ ),
1547
+ ),
1548
+ );
1549
+ },
1550
+ );
1551
+ }
1552
+ }
1553
+
1554
+ class _DesktopCloseDecision {
1555
+ const _DesktopCloseDecision({
1556
+ required this.keepRunning,
1557
+ required this.rememberChoice,
1558
+ });
1559
+
1560
+ final bool keepRunning;
1561
+ final bool rememberChoice;
1562
+ }
1563
+
1564
+ class _RecordingPermissionBadge extends StatelessWidget {
1565
+ const _RecordingPermissionBadge({required this.label, required this.state});
1566
+
1567
+ final String label;
1568
+ final RecordingPermissionState state;
1569
+
1570
+ @override
1571
+ Widget build(BuildContext context) {
1572
+ final (color, icon, text) = switch (state) {
1573
+ RecordingPermissionState.granted => (
1574
+ _success,
1575
+ Icons.check_circle,
1576
+ 'Ready',
1577
+ ),
1578
+ RecordingPermissionState.denied => (
1579
+ _danger,
1580
+ Icons.lock_outline,
1581
+ 'Blocked',
1582
+ ),
1583
+ RecordingPermissionState.needsRestart => (
1584
+ _warning,
1585
+ Icons.restart_alt_rounded,
1586
+ 'Restart needed',
1587
+ ),
1588
+ RecordingPermissionState.unsupported => (
1589
+ _textSecondary,
1590
+ Icons.do_not_disturb_alt_outlined,
1591
+ 'Unsupported',
1592
+ ),
1593
+ RecordingPermissionState.unknown => (
1594
+ _warning,
1595
+ Icons.help_outline,
1596
+ 'Check access',
1597
+ ),
1598
+ };
1599
+
1600
+ return Container(
1601
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
1602
+ decoration: BoxDecoration(
1603
+ color: color.withValues(alpha: 0.10),
1604
+ borderRadius: BorderRadius.circular(16),
1605
+ border: Border.all(color: color.withValues(alpha: 0.20)),
1606
+ ),
1607
+ child: Row(
1608
+ mainAxisSize: MainAxisSize.min,
1609
+ children: <Widget>[
1610
+ Icon(icon, size: 16, color: color),
1611
+ const SizedBox(width: 8),
1612
+ Text(
1613
+ '$label · $text',
1614
+ style: TextStyle(color: color, fontWeight: FontWeight.w700),
1615
+ ),
1616
+ ],
1617
+ ),
1618
+ );
1619
+ }
1620
+ }
1621
+
1622
+ class _CompanionPermissionBadge extends StatelessWidget {
1623
+ const _CompanionPermissionBadge({required this.label, required this.state});
1624
+
1625
+ final String label;
1626
+ final String state;
1627
+
1628
+ @override
1629
+ Widget build(BuildContext context) {
1630
+ final normalized = state.trim().toLowerCase();
1631
+ final (color, icon, text) = switch (normalized) {
1632
+ 'available' => (_success, Icons.check_circle, 'Granted'),
1633
+ 'required' => (_warning, Icons.lock_outline, 'Needs access'),
1634
+ 'unsupported' => (
1635
+ _textSecondary,
1636
+ Icons.do_not_disturb_alt_outlined,
1637
+ 'Unsupported',
1638
+ ),
1639
+ _ => (_warning, Icons.help_outline, 'Unknown'),
1640
+ };
1641
+ return Container(
1642
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
1643
+ decoration: BoxDecoration(
1644
+ color: color.withValues(alpha: 0.10),
1645
+ borderRadius: BorderRadius.circular(16),
1646
+ border: Border.all(color: color.withValues(alpha: 0.20)),
1647
+ ),
1648
+ child: Row(
1649
+ mainAxisSize: MainAxisSize.min,
1650
+ children: <Widget>[
1651
+ Icon(icon, size: 16, color: color),
1652
+ const SizedBox(width: 8),
1653
+ Text(
1654
+ '$label · $text',
1655
+ style: TextStyle(color: color, fontWeight: FontWeight.w700),
1656
+ ),
1657
+ ],
1658
+ ),
1659
+ );
1660
+ }
1661
+ }
1662
+
1663
+ class _AudioLevelBar extends StatelessWidget {
1664
+ const _AudioLevelBar({
1665
+ required this.label,
1666
+ required this.valueDb,
1667
+ required this.color,
1668
+ this.compact = false,
1669
+ });
1670
+
1671
+ final String label;
1672
+ final double valueDb;
1673
+ final Color color;
1674
+ final bool compact;
1675
+
1676
+ @override
1677
+ Widget build(BuildContext context) {
1678
+ final progress = ((valueDb + 72) / 72).clamp(0.0, 1.0);
1679
+ return SizedBox(
1680
+ width: compact ? 148 : 220,
1681
+ child: Column(
1682
+ crossAxisAlignment: CrossAxisAlignment.start,
1683
+ children: <Widget>[
1684
+ Row(
1685
+ children: <Widget>[
1686
+ Text(
1687
+ label,
1688
+ style: TextStyle(
1689
+ color: _textSecondary,
1690
+ fontSize: compact ? 11 : 12,
1691
+ fontWeight: FontWeight.w700,
1692
+ letterSpacing: 0.3,
1693
+ ),
1694
+ ),
1695
+ const Spacer(),
1696
+ Text(
1697
+ valueDb <= -119 ? 'Silent' : '${valueDb.toStringAsFixed(0)} dB',
1698
+ style: TextStyle(
1699
+ color: _textSecondary,
1700
+ fontSize: compact ? 11 : 12,
1701
+ ),
1702
+ ),
1703
+ ],
1704
+ ),
1705
+ const SizedBox(height: 8),
1706
+ ClipRRect(
1707
+ borderRadius: BorderRadius.circular(999),
1708
+ child: LinearProgressIndicator(
1709
+ value: progress,
1710
+ minHeight: compact ? 7 : 8,
1711
+ color: color,
1712
+ backgroundColor: _borderLight,
1713
+ ),
1714
+ ),
1715
+ ],
1716
+ ),
1717
+ );
1718
+ }
1719
+ }
1720
+
1721
+ class _DesktopFloatingToolbar extends StatefulWidget {
1722
+ const _DesktopFloatingToolbar({required this.controller});
1723
+
1724
+ final NeoAgentController controller;
1725
+
1726
+ @override
1727
+ State<_DesktopFloatingToolbar> createState() =>
1728
+ _DesktopFloatingToolbarState();
1729
+ }
1730
+
1731
+ class _DesktopFloatingToolbarState extends State<_DesktopFloatingToolbar> {
1732
+ Timer? _ticker;
1733
+
1734
+ @override
1735
+ void initState() {
1736
+ super.initState();
1737
+ _syncTicker();
1738
+ }
1739
+
1740
+ @override
1741
+ void didUpdateWidget(covariant _DesktopFloatingToolbar oldWidget) {
1742
+ super.didUpdateWidget(oldWidget);
1743
+ _syncTicker();
1744
+ }
1745
+
1746
+ @override
1747
+ void dispose() {
1748
+ _ticker?.cancel();
1749
+ super.dispose();
1750
+ }
1751
+
1752
+ void _syncTicker() {
1753
+ final runtime = widget.controller.recordingRuntime;
1754
+ final shouldTick =
1755
+ runtime.active &&
1756
+ runtime.startedAt != null &&
1757
+ runtime.floatingToolbarVisible;
1758
+ if (!shouldTick) {
1759
+ _ticker?.cancel();
1760
+ _ticker = null;
1761
+ return;
1762
+ }
1763
+ _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
1764
+ if (mounted) {
1765
+ setState(() {});
1766
+ }
1767
+ });
1768
+ }
1769
+
1770
+ @override
1771
+ Widget build(BuildContext context) {
1772
+ final controller = widget.controller;
1773
+ final runtime = controller.recordingRuntime;
1774
+ if (!runtime.supportsFloatingToolbar ||
1775
+ !runtime.active ||
1776
+ !runtime.floatingToolbarVisible) {
1777
+ return const SizedBox.shrink();
1778
+ }
1779
+
1780
+ final elapsed = runtime.startedAt == null
1781
+ ? '00:00'
1782
+ : _formatDuration(
1783
+ DateTime.now().difference(runtime.startedAt!).inMilliseconds,
1784
+ );
1785
+
1786
+ return Positioned(
1787
+ top: 10,
1788
+ left: 0,
1789
+ right: 0,
1790
+ child: SafeArea(
1791
+ child: IgnorePointer(
1792
+ ignoring: false,
1793
+ child: Align(
1794
+ alignment: Alignment.topCenter,
1795
+ child: _DesktopFloatingToolbarSurface(
1796
+ controller: controller,
1797
+ elapsedLabel: elapsed,
1798
+ compactWindow: false,
1799
+ onOpenMainWindow: null,
1800
+ ),
1801
+ ),
1802
+ ),
1803
+ ),
1804
+ );
1805
+ }
1806
+ }
1807
+
1808
+ class _DetachedDesktopFloatingToolbarShell extends StatefulWidget {
1809
+ const _DetachedDesktopFloatingToolbarShell({
1810
+ required this.controller,
1811
+ required this.onOpenMainWindow,
1812
+ });
1813
+
1814
+ final NeoAgentController controller;
1815
+ final Future<void> Function() onOpenMainWindow;
1816
+
1817
+ @override
1818
+ State<_DetachedDesktopFloatingToolbarShell> createState() =>
1819
+ _DetachedDesktopFloatingToolbarShellState();
1820
+ }
1821
+
1822
+ class _DetachedDesktopFloatingToolbarShellState
1823
+ extends State<_DetachedDesktopFloatingToolbarShell> {
1824
+ Timer? _ticker;
1825
+
1826
+ @override
1827
+ void initState() {
1828
+ super.initState();
1829
+ _syncTicker();
1830
+ }
1831
+
1832
+ @override
1833
+ void didUpdateWidget(
1834
+ covariant _DetachedDesktopFloatingToolbarShell oldWidget,
1835
+ ) {
1836
+ super.didUpdateWidget(oldWidget);
1837
+ _syncTicker();
1838
+ }
1839
+
1840
+ @override
1841
+ void dispose() {
1842
+ _ticker?.cancel();
1843
+ super.dispose();
1844
+ }
1845
+
1846
+ void _syncTicker() {
1847
+ final runtime = widget.controller.recordingRuntime;
1848
+ final shouldTick =
1849
+ runtime.active &&
1850
+ runtime.startedAt != null &&
1851
+ runtime.floatingToolbarVisible;
1852
+ if (!shouldTick) {
1853
+ _ticker?.cancel();
1854
+ _ticker = null;
1855
+ return;
1856
+ }
1857
+ _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
1858
+ if (mounted) {
1859
+ setState(() {});
1860
+ }
1861
+ });
1862
+ }
1863
+
1864
+ @override
1865
+ Widget build(BuildContext context) {
1866
+ final runtime = widget.controller.recordingRuntime;
1867
+ if (!runtime.active || !runtime.floatingToolbarVisible) {
1868
+ return const SizedBox.shrink();
1869
+ }
1870
+
1871
+ final elapsed = runtime.startedAt == null
1872
+ ? '00:00'
1873
+ : _formatDuration(
1874
+ DateTime.now().difference(runtime.startedAt!).inMilliseconds,
1875
+ );
1876
+
1877
+ return DecoratedBox(
1878
+ decoration: const BoxDecoration(color: Colors.transparent),
1879
+ child: Scaffold(
1880
+ backgroundColor: Colors.transparent,
1881
+ body: SafeArea(
1882
+ child: Center(
1883
+ child: Padding(
1884
+ padding: const EdgeInsets.all(10),
1885
+ child: _DesktopFloatingToolbarSurface(
1886
+ controller: widget.controller,
1887
+ elapsedLabel: elapsed,
1888
+ compactWindow: true,
1889
+ onOpenMainWindow: widget.onOpenMainWindow,
1890
+ ),
1891
+ ),
1892
+ ),
1893
+ ),
1894
+ ),
1895
+ );
1896
+ }
1897
+ }
1898
+
1899
+ bool _desktopAssistantUsesToggleControls() {
1900
+ if (kIsWeb) {
1901
+ return false;
1902
+ }
1903
+ switch (defaultTargetPlatform) {
1904
+ case TargetPlatform.macOS:
1905
+ case TargetPlatform.windows:
1906
+ case TargetPlatform.linux:
1907
+ return true;
1908
+ case TargetPlatform.android:
1909
+ case TargetPlatform.iOS:
1910
+ case TargetPlatform.fuchsia:
1911
+ return false;
1912
+ }
1913
+ }
1914
+
1915
+ String _desktopAssistantPrimaryLabel(bool isCapturing) {
1916
+ if (_desktopAssistantUsesToggleControls()) {
1917
+ return isCapturing ? 'Stop and send' : 'Start talking';
1918
+ }
1919
+ return isCapturing ? 'Release to send' : 'Hold to talk';
1920
+ }
1921
+
1922
+ String _desktopAssistantPrimaryCaption(bool isCapturing) {
1923
+ if (_desktopAssistantUsesToggleControls()) {
1924
+ return isCapturing
1925
+ ? 'Commit the active live capture'
1926
+ : 'Click once to begin capturing';
1927
+ }
1928
+ return isCapturing
1929
+ ? 'Stop capture and submit'
1930
+ : 'Press and hold for quick capture';
1931
+ }
1932
+
1933
+ String _desktopAssistantIdleHint() {
1934
+ return _desktopAssistantUsesToggleControls()
1935
+ ? 'Click the mic to start speaking'
1936
+ : 'Hold Ctrl+Shift+Space to talk';
1937
+ }
1938
+
1939
+ String _desktopAssistantScreenContextHint(bool enabled) {
1940
+ return enabled ? 'Current screen will be attached' : 'Audio only';
1941
+ }
1942
+
1943
+ class _DesktopAssistantControlState {
1944
+ const _DesktopAssistantControlState({
1945
+ required this.isCapturing,
1946
+ required this.isBusy,
1947
+ required this.useToggleCapture,
1948
+ required this.statusLabel,
1949
+ required this.statusColor,
1950
+ required this.transcriptPreview,
1951
+ required this.primaryLabel,
1952
+ required this.primaryCaption,
1953
+ required this.primaryIcon,
1954
+ required this.primaryColor,
1955
+ required this.idleHint,
1956
+ required this.screenContextHint,
1957
+ required this.sourceSummary,
1958
+ });
1959
+
1960
+ factory _DesktopAssistantControlState.fromController(
1961
+ NeoAgentController controller, {
1962
+ required bool blockedHintVisible,
1963
+ }) {
1964
+ final liveState = controller.voiceAssistantLiveState;
1965
+ final isCapturing = controller.isLiveVoiceCaptureEngaged;
1966
+ final includeScreenContext = controller.voiceAssistantIncludeScreenContext;
1967
+ final useToggleCapture = _desktopAssistantUsesToggleControls();
1968
+ final transcriptPreview = liveState.partialTranscript.trim().isEmpty
1969
+ ? liveState.finalTranscript.trim()
1970
+ : liveState.partialTranscript.trim();
1971
+ return _DesktopAssistantControlState(
1972
+ isCapturing: isCapturing,
1973
+ isBusy: liveState.isBusy,
1974
+ useToggleCapture: useToggleCapture,
1975
+ statusLabel: blockedHintVisible
1976
+ ? 'Assistant unavailable while recording'
1977
+ : (isCapturing
1978
+ ? _desktopAssistantPrimaryLabel(true)
1979
+ : _desktopAssistantStatusLabel(liveState.state)),
1980
+ statusColor: blockedHintVisible
1981
+ ? _warning
1982
+ : (isCapturing ? _success : _accent),
1983
+ transcriptPreview: transcriptPreview,
1984
+ primaryLabel: _desktopAssistantPrimaryLabel(isCapturing),
1985
+ primaryCaption: _desktopAssistantPrimaryCaption(isCapturing),
1986
+ primaryIcon: isCapturing ? Icons.stop_rounded : Icons.mic,
1987
+ primaryColor: isCapturing ? _warning : _success,
1988
+ idleHint: _desktopAssistantIdleHint(),
1989
+ screenContextHint: _desktopAssistantScreenContextHint(
1990
+ includeScreenContext,
1991
+ ),
1992
+ sourceSummary: includeScreenContext ? 'Mic + screen' : 'Direct mic',
1993
+ );
1994
+ }
1995
+
1996
+ final bool isCapturing;
1997
+ final bool isBusy;
1998
+ final bool useToggleCapture;
1999
+ final String statusLabel;
2000
+ final Color statusColor;
2001
+ final String transcriptPreview;
2002
+ final String primaryLabel;
2003
+ final String primaryCaption;
2004
+ final IconData primaryIcon;
2005
+ final Color primaryColor;
2006
+ final String idleHint;
2007
+ final String screenContextHint;
2008
+ final String sourceSummary;
2009
+ }
2010
+
2011
+ class _VoiceAssistantScreenContextButton extends StatelessWidget {
2012
+ const _VoiceAssistantScreenContextButton({
2013
+ required this.controller,
2014
+ required this.compact,
2015
+ });
2016
+
2017
+ final NeoAgentController controller;
2018
+ final bool compact;
2019
+
2020
+ @override
2021
+ Widget build(BuildContext context) {
2022
+ final enabled = controller.voiceAssistantIncludeScreenContext;
2023
+ final onPressed = controller.canCaptureVoiceAssistantScreenContext
2024
+ ? () {
2025
+ unawaited(controller.toggleVoiceAssistantScreenContext());
2026
+ }
2027
+ : null;
2028
+
2029
+ if (compact) {
2030
+ return IconButton(
2031
+ tooltip: enabled
2032
+ ? 'Stop including the current screen'
2033
+ : 'Include the current screen',
2034
+ onPressed: onPressed,
2035
+ style: IconButton.styleFrom(
2036
+ visualDensity: VisualDensity.compact,
2037
+ padding: const EdgeInsets.all(8),
2038
+ minimumSize: const Size(30, 30),
2039
+ backgroundColor: enabled
2040
+ ? _accent.withValues(alpha: 0.14)
2041
+ : _bgSecondary.withValues(alpha: 0.9),
2042
+ foregroundColor: enabled ? _accent : _textSecondary,
2043
+ ),
2044
+ icon: Icon(
2045
+ enabled
2046
+ ? Icons.desktop_windows_rounded
2047
+ : Icons.desktop_windows_outlined,
2048
+ size: 15,
2049
+ ),
2050
+ );
2051
+ }
2052
+
2053
+ return _VoiceAssistantActionButton(
2054
+ icon: enabled
2055
+ ? Icons.desktop_windows_rounded
2056
+ : Icons.desktop_windows_outlined,
2057
+ label: enabled ? 'Screen on' : 'Screen off',
2058
+ onTap: onPressed,
2059
+ );
2060
+ }
2061
+ }
2062
+
2063
+ class _DesktopAssistantPopupShell extends StatelessWidget {
2064
+ const _DesktopAssistantPopupShell({
2065
+ required this.controller,
2066
+ required this.blockedHintVisible,
2067
+ required this.onPrimaryAction,
2068
+ required this.onCancel,
2069
+ });
2070
+
2071
+ final NeoAgentController controller;
2072
+ final bool blockedHintVisible;
2073
+ final Future<void> Function() onPrimaryAction;
2074
+ final Future<void> Function() onCancel;
2075
+
2076
+ @override
2077
+ Widget build(BuildContext context) {
2078
+ final assistantUi = _DesktopAssistantControlState.fromController(
2079
+ controller,
2080
+ blockedHintVisible: blockedHintVisible,
2081
+ );
2082
+
2083
+ return DecoratedBox(
2084
+ decoration: const BoxDecoration(color: Colors.transparent),
2085
+ child: Scaffold(
2086
+ backgroundColor: Colors.transparent,
2087
+ body: SafeArea(
2088
+ child: Align(
2089
+ alignment: Alignment.bottomCenter,
2090
+ child: Padding(
2091
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
2092
+ child: Material(
2093
+ color: Colors.transparent,
2094
+ child: Container(
2095
+ constraints: const BoxConstraints(maxWidth: 430),
2096
+ padding: const EdgeInsets.symmetric(
2097
+ horizontal: 12,
2098
+ vertical: 10,
2099
+ ),
2100
+ decoration: BoxDecoration(
2101
+ gradient: LinearGradient(
2102
+ colors: <Color>[
2103
+ _bgCard.withValues(alpha: 0.99),
2104
+ _bgSecondary.withValues(alpha: 0.97),
2105
+ ],
2106
+ ),
2107
+ borderRadius: BorderRadius.circular(999),
2108
+ border: Border.all(
2109
+ color: _borderLight.withValues(alpha: 0.9),
2110
+ ),
2111
+ boxShadow: <BoxShadow>[
2112
+ BoxShadow(
2113
+ color: Colors.black.withValues(alpha: 0.2),
2114
+ blurRadius: 18,
2115
+ offset: const Offset(0, 8),
2116
+ ),
2117
+ ],
2118
+ ),
2119
+ child: Row(
2120
+ mainAxisSize: MainAxisSize.max,
2121
+ children: <Widget>[
2122
+ _DesktopAssistantPulseDots(
2123
+ color: assistantUi.statusColor,
2124
+ active: assistantUi.isCapturing || assistantUi.isBusy,
2125
+ ),
2126
+ const SizedBox(width: 12),
2127
+ _DesktopAssistantWaveform(
2128
+ color: assistantUi.statusColor,
2129
+ active: assistantUi.isCapturing,
2130
+ busy: assistantUi.isBusy,
2131
+ ),
2132
+ const SizedBox(width: 12),
2133
+ Expanded(
2134
+ child: Column(
2135
+ mainAxisSize: MainAxisSize.min,
2136
+ crossAxisAlignment: CrossAxisAlignment.start,
2137
+ children: <Widget>[
2138
+ Text(
2139
+ assistantUi.statusLabel,
2140
+ maxLines: 1,
2141
+ overflow: TextOverflow.ellipsis,
2142
+ style: TextStyle(
2143
+ color: _textPrimary,
2144
+ fontWeight: FontWeight.w700,
2145
+ fontSize: 12.5,
2146
+ ),
2147
+ ),
2148
+ if (!blockedHintVisible)
2149
+ Text(
2150
+ assistantUi.transcriptPreview.isEmpty
2151
+ ? '${assistantUi.idleHint} • ${assistantUi.screenContextHint}'
2152
+ : assistantUi.transcriptPreview,
2153
+ maxLines: 1,
2154
+ overflow: TextOverflow.ellipsis,
2155
+ style: TextStyle(
2156
+ color: _textMuted,
2157
+ fontSize: 11.5,
2158
+ height: 1.35,
2159
+ ),
2160
+ ),
2161
+ ],
2162
+ ),
2163
+ ),
2164
+ const SizedBox(width: 8),
2165
+ _VoiceAssistantScreenContextButton(
2166
+ controller: controller,
2167
+ compact: true,
2168
+ ),
2169
+ const SizedBox(width: 4),
2170
+ FilledButton.icon(
2171
+ onPressed: blockedHintVisible
2172
+ ? null
2173
+ : () {
2174
+ unawaited(onPrimaryAction());
2175
+ },
2176
+ style: FilledButton.styleFrom(
2177
+ visualDensity: VisualDensity.compact,
2178
+ padding: const EdgeInsets.symmetric(
2179
+ horizontal: 12,
2180
+ vertical: 10,
2181
+ ),
2182
+ minimumSize: const Size(0, 38),
2183
+ backgroundColor: assistantUi.statusColor,
2184
+ foregroundColor: Colors.white,
2185
+ ),
2186
+ icon: Icon(
2187
+ assistantUi.isCapturing
2188
+ ? Icons.stop_rounded
2189
+ : Icons.mic_rounded,
2190
+ size: 16,
2191
+ ),
2192
+ label: Text(
2193
+ assistantUi.isCapturing ? 'Send' : 'Talk',
2194
+ style: const TextStyle(fontWeight: FontWeight.w700),
2195
+ ),
2196
+ ),
2197
+ IconButton(
2198
+ tooltip: 'Cancel',
2199
+ onPressed: () {
2200
+ unawaited(onCancel());
2201
+ },
2202
+ style: IconButton.styleFrom(
2203
+ visualDensity: VisualDensity.compact,
2204
+ padding: const EdgeInsets.all(8),
2205
+ minimumSize: const Size(30, 30),
2206
+ backgroundColor: _bgSecondary.withValues(alpha: 0.9),
2207
+ foregroundColor: _textSecondary,
2208
+ ),
2209
+ icon: const Icon(Icons.close_rounded, size: 14),
2210
+ ),
2211
+ ],
2212
+ ),
2213
+ ),
2214
+ ),
2215
+ ),
2216
+ ),
2217
+ ),
2218
+ ),
2219
+ );
2220
+ }
2221
+ }
2222
+
2223
+ String _desktopAssistantStatusLabel(String state) {
2224
+ switch (state.trim().toLowerCase()) {
2225
+ case 'transcribing':
2226
+ return 'Transcribing';
2227
+ case 'thinking':
2228
+ return 'Thinking';
2229
+ case 'speaking':
2230
+ return 'Speaking';
2231
+ case 'listening':
2232
+ return 'Listening';
2233
+ case 'idle':
2234
+ default:
2235
+ return 'Ready';
2236
+ }
2237
+ }
2238
+
2239
+ class _DesktopAssistantPulseDots extends StatelessWidget {
2240
+ const _DesktopAssistantPulseDots({required this.color, required this.active});
2241
+
2242
+ final Color color;
2243
+ final bool active;
2244
+
2245
+ @override
2246
+ Widget build(BuildContext context) {
2247
+ return SizedBox(
2248
+ width: 26,
2249
+ child: Wrap(
2250
+ spacing: 3,
2251
+ runSpacing: 3,
2252
+ children: List<Widget>.generate(6, (index) {
2253
+ final opacity = active ? 0.35 + (index % 3) * 0.2 : 0.28;
2254
+ return Container(
2255
+ width: 5,
2256
+ height: 5,
2257
+ decoration: BoxDecoration(
2258
+ color: color.withValues(alpha: opacity),
2259
+ shape: BoxShape.circle,
2260
+ ),
2261
+ );
2262
+ }),
2263
+ ),
2264
+ );
2265
+ }
2266
+ }
2267
+
2268
+ class _DesktopAssistantWaveform extends StatefulWidget {
2269
+ const _DesktopAssistantWaveform({
2270
+ required this.color,
2271
+ required this.active,
2272
+ required this.busy,
2273
+ });
2274
+
2275
+ final Color color;
2276
+ final bool active;
2277
+ final bool busy;
2278
+
2279
+ @override
2280
+ State<_DesktopAssistantWaveform> createState() =>
2281
+ _DesktopAssistantWaveformState();
2282
+ }
2283
+
2284
+ class _DesktopAssistantWaveformState extends State<_DesktopAssistantWaveform>
2285
+ with SingleTickerProviderStateMixin {
2286
+ late final AnimationController _controller;
2287
+
2288
+ @override
2289
+ void initState() {
2290
+ super.initState();
2291
+ _controller = AnimationController(
2292
+ vsync: this,
2293
+ duration: const Duration(milliseconds: 980),
2294
+ );
2295
+ _syncAnimation();
2296
+ }
2297
+
2298
+ @override
2299
+ void didUpdateWidget(covariant _DesktopAssistantWaveform oldWidget) {
2300
+ super.didUpdateWidget(oldWidget);
2301
+ _syncAnimation();
2302
+ }
2303
+
2304
+ @override
2305
+ void dispose() {
2306
+ _controller.dispose();
2307
+ super.dispose();
2308
+ }
2309
+
2310
+ void _syncAnimation() {
2311
+ if (widget.active || widget.busy) {
2312
+ if (!_controller.isAnimating) {
2313
+ _controller.repeat();
2314
+ }
2315
+ return;
2316
+ }
2317
+ _controller.stop();
2318
+ _controller.value = 0;
2319
+ }
2320
+
2321
+ @override
2322
+ Widget build(BuildContext context) {
2323
+ const barCount = 18;
2324
+ return SizedBox(
2325
+ width: 116,
2326
+ height: 18,
2327
+ child: AnimatedBuilder(
2328
+ animation: _controller,
2329
+ builder: (context, child) {
2330
+ return Row(
2331
+ children: List<Widget>.generate(barCount, (index) {
2332
+ final phase = _controller.value * 2 * math.pi;
2333
+ final wave = math.sin(phase + index * 0.55);
2334
+ final minHeight = widget.busy ? 3.0 : 2.0;
2335
+ final maxHeight = widget.active
2336
+ ? 12.0
2337
+ : (widget.busy ? 7.0 : 3.0);
2338
+ final normalized = widget.active || widget.busy
2339
+ ? (wave + 1) / 2
2340
+ : 0.2;
2341
+ final height = minHeight + (maxHeight - minHeight) * normalized;
2342
+ return Padding(
2343
+ padding: const EdgeInsets.only(right: 2),
2344
+ child: Align(
2345
+ alignment: Alignment.bottomCenter,
2346
+ child: Container(
2347
+ width: 3,
2348
+ height: height,
2349
+ decoration: BoxDecoration(
2350
+ color: widget.color.withValues(
2351
+ alpha: widget.active ? 0.9 : (widget.busy ? 0.65 : 0.4),
2352
+ ),
2353
+ borderRadius: BorderRadius.circular(99),
2354
+ ),
2355
+ ),
2356
+ ),
2357
+ );
2358
+ }),
2359
+ );
2360
+ },
2361
+ ),
2362
+ );
2363
+ }
2364
+ }
2365
+
2366
+ class _DesktopFloatingToolbarSurface extends StatelessWidget {
2367
+ const _DesktopFloatingToolbarSurface({
2368
+ required this.controller,
2369
+ required this.elapsedLabel,
2370
+ required this.compactWindow,
2371
+ required this.onOpenMainWindow,
2372
+ });
2373
+
2374
+ final NeoAgentController controller;
2375
+ final String elapsedLabel;
2376
+ final bool compactWindow;
2377
+ final Future<void> Function()? onOpenMainWindow;
2378
+
2379
+ @override
2380
+ Widget build(BuildContext context) {
2381
+ final runtime = controller.recordingRuntime;
2382
+ return Material(
2383
+ color: Colors.transparent,
2384
+ child: Container(
2385
+ constraints: BoxConstraints(
2386
+ maxWidth: compactWindow ? double.infinity : 680,
2387
+ ),
2388
+ margin: compactWindow
2389
+ ? EdgeInsets.zero
2390
+ : const EdgeInsets.symmetric(horizontal: 16),
2391
+ padding: EdgeInsets.symmetric(
2392
+ horizontal: compactWindow ? 10 : 14,
2393
+ vertical: compactWindow ? 8 : 12,
2394
+ ),
2395
+ decoration: BoxDecoration(
2396
+ gradient: LinearGradient(
2397
+ colors: <Color>[
2398
+ _bgCard.withValues(alpha: 0.98),
2399
+ _bgCard.withValues(alpha: 0.92),
2400
+ ],
2401
+ ),
2402
+ borderRadius: BorderRadius.circular(compactWindow ? 22 : 24),
2403
+ border: Border.all(color: _borderLight),
2404
+ boxShadow: <BoxShadow>[
2405
+ BoxShadow(
2406
+ color: Colors.black.withValues(alpha: 0.24),
2407
+ blurRadius: 24,
2408
+ offset: const Offset(0, 12),
2409
+ ),
2410
+ ],
2411
+ ),
2412
+ child: Wrap(
2413
+ spacing: 10,
2414
+ runSpacing: 10,
2415
+ crossAxisAlignment: WrapCrossAlignment.center,
2416
+ children: <Widget>[
2417
+ if (compactWindow)
2418
+ const _BrandLockup(
2419
+ logoSize: 34,
2420
+ titleFontSize: 16,
2421
+ direction: Axis.horizontal,
2422
+ spacing: 10,
2423
+ alignment: CrossAxisAlignment.start,
2424
+ ),
2425
+ if (compactWindow)
2426
+ DragToMoveArea(
2427
+ child: Container(
2428
+ padding: const EdgeInsets.symmetric(
2429
+ horizontal: 8,
2430
+ vertical: 6,
2431
+ ),
2432
+ decoration: BoxDecoration(
2433
+ color: _bgSecondary.withValues(alpha: 0.78),
2434
+ borderRadius: BorderRadius.circular(12),
2435
+ border: Border.all(color: _borderLight),
2436
+ ),
2437
+ child: Icon(
2438
+ Icons.drag_indicator_rounded,
2439
+ size: 14,
2440
+ color: _textMuted,
2441
+ ),
2442
+ ),
2443
+ ),
2444
+ Container(
2445
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
2446
+ decoration: BoxDecoration(
2447
+ color: (runtime.paused ? _warning : _danger).withValues(
2448
+ alpha: 0.10,
2449
+ ),
2450
+ borderRadius: BorderRadius.circular(16),
2451
+ border: Border.all(
2452
+ color: (runtime.paused ? _warning : _danger).withValues(
2453
+ alpha: 0.20,
2454
+ ),
2455
+ ),
2456
+ ),
2457
+ child: Row(
2458
+ mainAxisSize: MainAxisSize.min,
2459
+ children: <Widget>[
2460
+ Icon(
2461
+ runtime.paused
2462
+ ? Icons.pause_circle_outline
2463
+ : Icons.fiber_manual_record_rounded,
2464
+ color: runtime.paused ? _warning : _danger,
2465
+ size: 18,
2466
+ ),
2467
+ const SizedBox(width: 8),
2468
+ Text(
2469
+ runtime.paused ? 'Paused' : 'Recording',
2470
+ style: TextStyle(
2471
+ color: runtime.paused ? _warning : _danger,
2472
+ fontSize: 12,
2473
+ fontWeight: FontWeight.w800,
2474
+ ),
2475
+ ),
2476
+ const SizedBox(width: 10),
2477
+ Text(
2478
+ elapsedLabel,
2479
+ style: TextStyle(
2480
+ color: _textPrimary,
2481
+ fontSize: 12,
2482
+ fontWeight: FontWeight.w700,
2483
+ ),
2484
+ ),
2485
+ ],
2486
+ ),
2487
+ ),
2488
+ _AudioLevelBar(
2489
+ label: 'MIC',
2490
+ valueDb: runtime.microphoneLevelDb,
2491
+ color: _accent,
2492
+ compact: true,
2493
+ ),
2494
+ _AudioLevelBar(
2495
+ label: 'SYSTEM',
2496
+ valueDb: runtime.systemAudioLevelDb,
2497
+ color: _accentAlt,
2498
+ compact: true,
2499
+ ),
2500
+ if (compactWindow && onOpenMainWindow != null)
2501
+ IconButton(
2502
+ tooltip: 'Open NeoAgent',
2503
+ onPressed: onOpenMainWindow,
2504
+ style: IconButton.styleFrom(
2505
+ backgroundColor: _bgSecondary,
2506
+ foregroundColor: _textPrimary,
2507
+ ),
2508
+ icon: const Icon(Icons.open_in_full_rounded),
2509
+ ),
2510
+ IconButton(
2511
+ tooltip: runtime.paused ? 'Resume recording' : 'Pause recording',
2512
+ onPressed: runtime.paused
2513
+ ? controller.resumeDesktopRecording
2514
+ : controller.pauseDesktopRecording,
2515
+ style: IconButton.styleFrom(
2516
+ backgroundColor: _bgSecondary,
2517
+ foregroundColor: _textPrimary,
2518
+ ),
2519
+ icon: Icon(
2520
+ runtime.paused ? Icons.play_arrow_rounded : Icons.pause_rounded,
2521
+ ),
2522
+ ),
2523
+ IconButton(
2524
+ tooltip: 'Stop recording',
2525
+ onPressed: controller.isStoppingRecording
2526
+ ? null
2527
+ : controller.stopRecording,
2528
+ style: IconButton.styleFrom(
2529
+ backgroundColor: _danger.withValues(alpha: 0.12),
2530
+ foregroundColor: _danger,
2531
+ ),
2532
+ icon: const Icon(Icons.stop_rounded),
2533
+ ),
2534
+ IconButton(
2535
+ tooltip: 'Hide floating bar',
2536
+ onPressed: controller.hideDesktopFloatingToolbar,
2537
+ style: IconButton.styleFrom(
2538
+ backgroundColor: _bgSecondary,
2539
+ foregroundColor: _textSecondary,
2540
+ ),
2541
+ icon: const Icon(Icons.close_rounded),
2542
+ ),
2543
+ ],
2544
+ ),
2545
+ ),
2546
+ );
2547
+ }
2548
+ }
2549
+
2550
+ String _ensureModelValue(
2551
+ String value,
2552
+ List<ModelMeta> models, {
2553
+ required bool allowAuto,
2554
+ }) {
2555
+ if (allowAuto && value == 'auto') {
2556
+ return 'auto';
2557
+ }
2558
+ for (final model in models) {
2559
+ if (model.id == value) {
2560
+ return value;
2561
+ }
2562
+ }
2563
+ if (allowAuto) {
2564
+ return 'auto';
2565
+ }
2566
+ return models.isNotEmpty ? models.first.id : value;
2567
+ }
2568
+
2569
+ String _firstAvailableModelId(List<ModelMeta> models) {
2570
+ for (final model in models) {
2571
+ if (model.available) {
2572
+ return model.id;
2573
+ }
2574
+ }
2575
+ return models.isNotEmpty ? models.first.id : 'auto';
2576
+ }
2577
+
2578
+ String _modelLabelForValue(String value, List<ModelMeta> models) {
2579
+ if (value == 'auto' || value.trim().isEmpty) {
2580
+ return 'Auto';
2581
+ }
2582
+ for (final model in models) {
2583
+ if (model.id == value) {
2584
+ return model.label;
2585
+ }
2586
+ }
2587
+ return value;
2588
+ }
2589
+
2590
+ String _friendlyBaseUrlLabel(String value) {
2591
+ final uri = Uri.tryParse(value);
2592
+ if (uri == null || uri.host.trim().isEmpty) {
2593
+ return value;
2594
+ }
2595
+ final port = uri.hasPort ? ':${uri.port}' : '';
2596
+ return '${uri.host}$port';
2597
+ }
2598
+
2599
+ String? _androidRuntimeVersionLabel(Map<String, dynamic> runtime) {
2600
+ final apiLevel = _asInt(runtime['apiLevel']);
2601
+ final systemImage = runtime['systemImage']?.toString().trim() ?? '';
2602
+ if (apiLevel <= 0 && systemImage.isEmpty) {
2603
+ return null;
2604
+ }
2605
+
2606
+ if (apiLevel > 0) {
2607
+ return 'Android $apiLevel';
2608
+ }
2609
+ return systemImage;
2610
+ }
2611
+
2612
+ Map<String, dynamic> _jsonMap(dynamic value) {
2613
+ if (value is Map<String, dynamic>) {
2614
+ return value;
2615
+ }
2616
+ if (value is Map) {
2617
+ return Map<String, dynamic>.from(value);
2618
+ }
2619
+ return const <String, dynamic>{};
2620
+ }
2621
+
2622
+ List<dynamic> _jsonList(
2623
+ dynamic value, {
2624
+ List<String> nestedKeys = const <String>[
2625
+ 'items',
2626
+ 'data',
2627
+ 'results',
2628
+ 'rows',
2629
+ 'values',
2630
+ 'list',
2631
+ ],
2632
+ bool fallbackToMapValues = false,
2633
+ }) {
2634
+ if (value is List) {
2635
+ return value;
2636
+ }
2637
+ if (value is Map) {
2638
+ for (final key in nestedKeys) {
2639
+ final nested = value[key];
2640
+ if (nested is List) {
2641
+ return nested;
2642
+ }
2643
+ }
2644
+ if (fallbackToMapValues) {
2645
+ return value.values.toList(growable: false);
2646
+ }
2647
+ }
2648
+ return const <dynamic>[];
2649
+ }
2650
+
2651
+ List<Map<String, dynamic>> _jsonMapList(
2652
+ dynamic value, {
2653
+ List<String> nestedKeys = const <String>[
2654
+ 'items',
2655
+ 'data',
2656
+ 'results',
2657
+ 'rows',
2658
+ 'values',
2659
+ 'list',
2660
+ ],
2661
+ bool fallbackToMapValues = false,
2662
+ }) {
2663
+ return _jsonList(
2664
+ value,
2665
+ nestedKeys: nestedKeys,
2666
+ fallbackToMapValues: fallbackToMapValues,
2667
+ ).whereType<Map>().map((item) => Map<String, dynamic>.from(item)).toList();
2668
+ }
2669
+
2670
+ List<String> _jsonStringList(
2671
+ dynamic value, {
2672
+ List<String> nestedKeys = const <String>[
2673
+ 'items',
2674
+ 'data',
2675
+ 'results',
2676
+ 'rows',
2677
+ 'values',
2678
+ 'list',
2679
+ ],
2680
+ bool fallbackToMapValues = false,
2681
+ }) {
2682
+ return _jsonList(
2683
+ value,
2684
+ nestedKeys: nestedKeys,
2685
+ fallbackToMapValues: fallbackToMapValues,
2686
+ )
2687
+ .map((item) => item?.toString() ?? '')
2688
+ .where((item) => item.isNotEmpty)
2689
+ .toList();
2690
+ }
2691
+
2692
+ String _normalizeSuggestedWhitelistEntry(String platform, String entry) {
2693
+ final trimmed = entry.trim();
2694
+ if (trimmed.isEmpty) {
2695
+ return '';
2696
+ }
2697
+ switch (platform) {
2698
+ case 'whatsapp':
2699
+ return trimmed.replaceAll(RegExp(r'[^0-9]'), '');
2700
+ case 'telnyx':
2701
+ return trimmed.replaceAll(RegExp(r'[^0-9+]'), '');
2702
+ case 'discord':
2703
+ case 'telegram':
2704
+ return trimmed.replaceAll(
2705
+ RegExp(r'[^0-9a-z:_-]', caseSensitive: false),
2706
+ '',
2707
+ );
2708
+ default:
2709
+ return trimmed;
2710
+ }
2711
+ }
2712
+
2713
+ int _asInt(dynamic value) {
2714
+ if (value is int) {
2715
+ return value;
2716
+ }
2717
+ if (value is double) {
2718
+ return value.round();
2719
+ }
2720
+ return int.tryParse(value?.toString() ?? '') ?? 0;
2721
+ }
2722
+
2723
+ DateTime _parseTimestamp(String? raw) {
2724
+ if (raw == null || raw.isEmpty) {
2725
+ return DateTime.now();
2726
+ }
2727
+ final normalized = raw.contains('T') ? raw : '${raw.replaceFirst(' ', 'T')}Z';
2728
+ return DateTime.tryParse(normalized)?.toLocal() ?? DateTime.now();
2729
+ }
2730
+
2731
+ DateTime? _parseOptionalTimestamp(String? raw) {
2732
+ if (raw == null || raw.isEmpty) {
2733
+ return null;
2734
+ }
2735
+ return _parseTimestamp(raw);
2736
+ }
2737
+
2738
+ String _formatTimestamp(DateTime value) {
2739
+ final hour = value.hour.toString().padLeft(2, '0');
2740
+ final minute = value.minute.toString().padLeft(2, '0');
2741
+ final month = value.month.toString().padLeft(2, '0');
2742
+ final day = value.day.toString().padLeft(2, '0');
2743
+ return '$month/$day $hour:$minute';
2744
+ }
2745
+
2746
+ String _formatTimeOnly(DateTime value) {
2747
+ final hour = value.hour.toString().padLeft(2, '0');
2748
+ final minute = value.minute.toString().padLeft(2, '0');
2749
+ final second = value.second.toString().padLeft(2, '0');
2750
+ return '$hour:$minute:$second';
2751
+ }
2752
+
2753
+ String _formatDuration(int milliseconds) {
2754
+ final totalSeconds = math.max(0, milliseconds ~/ 1000);
2755
+ final hours = totalSeconds ~/ 3600;
2756
+ final minutes = (totalSeconds % 3600) ~/ 60;
2757
+ final seconds = totalSeconds % 60;
2758
+ if (hours > 0) {
2759
+ return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
2760
+ }
2761
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
2762
+ }
2763
+
2764
+ String _formatElapsed(Duration value) {
2765
+ final totalSeconds = math.max(0, value.inSeconds);
2766
+ final hours = totalSeconds ~/ 3600;
2767
+ final minutes = (totalSeconds % 3600) ~/ 60;
2768
+ final seconds = totalSeconds % 60;
2769
+ if (hours > 0) {
2770
+ return '${hours}h ${minutes}m';
2771
+ }
2772
+ if (minutes > 0) {
2773
+ return '${minutes}m ${seconds}s';
2774
+ }
2775
+ return '${seconds}s';
2776
+ }
2777
+
2778
+ String _formatNumber(int value) {
2779
+ final chars = value.abs().toString().split('').reversed.toList();
2780
+ final buffer = StringBuffer();
2781
+ for (var i = 0; i < chars.length; i++) {
2782
+ if (i > 0 && i % 3 == 0) {
2783
+ buffer.write('.');
2784
+ }
2785
+ buffer.write(chars[i]);
2786
+ }
2787
+ final formatted = buffer.toString().split('').reversed.join();
2788
+ return value < 0 ? '-$formatted' : formatted;
2789
+ }
2790
+
2791
+ String _summarizeToolArgs(dynamic raw) {
2792
+ if (raw is Map && raw.isNotEmpty) {
2793
+ final first = raw.entries.first;
2794
+ return '${first.key}: ${first.value}'.trim();
2795
+ }
2796
+ return '';
2797
+ }
2798
+
2799
+ String _summarizeToolResult(dynamic raw) {
2800
+ if (raw == null) {
2801
+ return '';
2802
+ }
2803
+ if (raw is Map) {
2804
+ if (raw['timedOut'] == true) {
2805
+ final durationMs = _asInt(raw['durationMs']);
2806
+ final durationText = durationMs > 0
2807
+ ? ' after ${_formatDuration(durationMs)}'
2808
+ : '';
2809
+ return 'Timed out$durationText';
2810
+ }
2811
+ if (raw['killed'] == true) {
2812
+ return 'Stopped before completion';
2813
+ }
2814
+ if (raw['error'] != null) {
2815
+ return raw['error'].toString();
2816
+ }
2817
+ if (raw['status'] != null && raw['status'].toString() == 'stopped') {
2818
+ return 'Stopped';
2819
+ }
2820
+ if (raw['message'] != null) {
2821
+ return raw['message'].toString();
2822
+ }
2823
+ if (raw['content'] != null) {
2824
+ return raw['content'].toString();
2825
+ }
2826
+ return raw.entries
2827
+ .take(2)
2828
+ .map((entry) => '${entry.key}: ${entry.value}')
2829
+ .join(' • ');
2830
+ }
2831
+ final text = raw.toString();
2832
+ return text.length > 140 ? '${text.substring(0, 140)}…' : text;
2833
+ }
2834
+
2835
+ String _titleCase(String value) {
2836
+ final normalized = value.trim();
2837
+ if (normalized.isEmpty) {
2838
+ return '';
2839
+ }
2840
+ return normalized
2841
+ .split(RegExp(r'\s+'))
2842
+ .map((part) {
2843
+ if (part.isEmpty) {
2844
+ return part;
2845
+ }
2846
+ return '${part[0].toUpperCase()}${part.substring(1)}';
2847
+ })
2848
+ .join(' ');
2849
+ }
2850
+
2851
+ String _truncateRunText(String value, {int maxLength = 1400}) {
2852
+ final trimmed = value.trim();
2853
+ if (trimmed.length <= maxLength) {
2854
+ return trimmed;
2855
+ }
2856
+ return '${trimmed.substring(0, maxLength)}\n\n…truncated…';
2857
+ }
2858
+
2859
+ extension on String {
2860
+ String ifEmpty(String fallback) => trim().isEmpty ? fallback : this;
2861
+ }