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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/.env.example +45 -0
  2. package/docs/capabilities.md +2 -2
  3. package/docs/configuration.md +6 -5
  4. package/docs/hardware.md +1 -1
  5. package/docs/integrations.md +2 -3
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +111 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +171 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +804 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/auto/NeoAgentCarAppService.kt +114 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnection.kt +118 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/telecom/NeoAgentConnectionService.kt +86 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  30. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  31. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  32. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  33. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  35. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  36. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  37. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  38. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  39. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  43. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  44. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  45. package/flutter_app/android/app/src/main/res/values/arrays.xml +8 -0
  46. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  48. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  49. package/flutter_app/android/app/src/main/res/xml/automotive_app_desc.xml +4 -0
  50. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  51. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  52. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  53. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  54. package/flutter_app/android/build.gradle.kts +24 -0
  55. package/flutter_app/android/ci-release.keystore +0 -0
  56. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  57. package/flutter_app/android/gradle.properties +3 -0
  58. package/flutter_app/android/key.properties +4 -0
  59. package/flutter_app/android/settings.gradle.kts +26 -0
  60. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  62. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  63. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  64. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  65. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  66. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  67. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  68. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  69. package/flutter_app/lib/features/location/location_service.dart +119 -0
  70. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  71. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +571 -0
  72. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +275 -0
  73. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +366 -0
  74. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +57 -0
  75. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +218 -0
  76. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +46 -0
  77. package/flutter_app/lib/main.dart +103 -0
  78. package/flutter_app/lib/main_account_settings.dart +1250 -0
  79. package/flutter_app/lib/main_admin.dart +886 -0
  80. package/flutter_app/lib/main_app_shell.dart +2134 -0
  81. package/flutter_app/lib/main_chat.dart +3316 -0
  82. package/flutter_app/lib/main_controller.dart +6836 -0
  83. package/flutter_app/lib/main_devices.dart +2396 -0
  84. package/flutter_app/lib/main_integrations.dart +909 -0
  85. package/flutter_app/lib/main_launcher.dart +959 -0
  86. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  87. package/flutter_app/lib/main_models.dart +3539 -0
  88. package/flutter_app/lib/main_navigation.dart +188 -0
  89. package/flutter_app/lib/main_operations.dart +4851 -0
  90. package/flutter_app/lib/main_recordings.dart +920 -0
  91. package/flutter_app/lib/main_runtime.dart +838 -0
  92. package/flutter_app/lib/main_settings.dart +2040 -0
  93. package/flutter_app/lib/main_shared.dart +3147 -0
  94. package/flutter_app/lib/main_theme.dart +257 -0
  95. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  96. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  97. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  98. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  99. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  100. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  101. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  102. package/flutter_app/lib/src/android_auto_bridge.dart +59 -0
  103. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  104. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  105. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  106. package/flutter_app/lib/src/backend_client.dart +1837 -0
  107. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  108. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  109. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  110. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  111. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  112. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  113. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  114. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  115. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  116. package/flutter_app/lib/src/health_bridge.dart +136 -0
  117. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  118. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  119. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  120. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  121. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  122. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  123. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  124. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  125. package/flutter_app/lib/src/oauth_launcher_io.dart +95 -0
  126. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  127. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  128. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  129. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  130. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  131. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  132. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  133. package/flutter_app/lib/src/theme/palette.dart +81 -0
  134. package/flutter_app/lib/src/web_app_update_monitor.dart +17 -0
  135. package/flutter_app/lib/src/web_app_update_monitor_stub.dart +24 -0
  136. package/flutter_app/lib/src/web_app_update_monitor_web.dart +123 -0
  137. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  138. package/flutter_app/linux/CMakeLists.txt +128 -0
  139. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  140. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +47 -0
  141. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  142. package/flutter_app/linux/flutter/generated_plugins.cmake +32 -0
  143. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  144. package/flutter_app/linux/runner/main.cc +6 -0
  145. package/flutter_app/linux/runner/my_application.cc +144 -0
  146. package/flutter_app/linux/runner/my_application.h +18 -0
  147. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  148. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  149. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  150. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +44 -0
  151. package/flutter_app/macos/Podfile +42 -0
  152. package/flutter_app/macos/Podfile.lock +87 -0
  153. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  154. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  155. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  156. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  157. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  158. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  159. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  160. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  161. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  162. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  163. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  164. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  165. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  166. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  167. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  168. package/flutter_app/macos/Runner/Info.plist +36 -0
  169. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  170. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  171. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  172. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  173. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  174. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  175. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  176. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  177. package/flutter_app/patch_strings.py +12 -0
  178. package/flutter_app/pubspec.lock +1224 -0
  179. package/flutter_app/pubspec.yaml +57 -0
  180. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  181. package/flutter_app/test/recording_payloads_test.dart +53 -0
  182. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  183. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  184. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  185. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  186. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  187. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  188. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  189. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  190. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  191. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  192. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  193. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  194. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  195. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  196. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  197. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  198. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  199. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  200. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  201. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  202. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  203. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  204. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  205. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  206. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  207. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  208. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  209. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  210. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  211. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  212. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  213. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  214. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  215. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  216. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  217. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  218. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  219. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  220. package/flutter_app/web/favicon.png +0 -0
  221. package/flutter_app/web/favicon.svg +12 -0
  222. package/flutter_app/web/icons/Icon-192.png +0 -0
  223. package/flutter_app/web/icons/Icon-512.png +0 -0
  224. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  225. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  226. package/flutter_app/web/index.html +39 -0
  227. package/flutter_app/web/manifest.json +35 -0
  228. package/flutter_app/windows/CMakeLists.txt +108 -0
  229. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  230. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +50 -0
  231. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  232. package/flutter_app/windows/flutter/generated_plugins.cmake +36 -0
  233. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  234. package/flutter_app/windows/runner/Runner.rc +121 -0
  235. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  236. package/flutter_app/windows/runner/flutter_window.h +37 -0
  237. package/flutter_app/windows/runner/main.cpp +53 -0
  238. package/flutter_app/windows/runner/resource.h +16 -0
  239. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  240. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  241. package/flutter_app/windows/runner/utils.cpp +65 -0
  242. package/flutter_app/windows/runner/utils.h +19 -0
  243. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  244. package/flutter_app/windows/runner/win32_window.h +102 -0
  245. package/lib/install_helpers.js +31 -0
  246. package/lib/manager.js +201 -6
  247. package/package.json +5 -3
  248. package/server/db/database.js +125 -1
  249. package/server/http/middleware.js +55 -2
  250. package/server/http/routes.js +1 -0
  251. package/server/http/static.js +41 -1
  252. package/server/index.js +3 -0
  253. package/server/public/.last_build_id +1 -1
  254. package/server/public/assets/AssetManifest.bin +1 -1
  255. package/server/public/assets/AssetManifest.bin.json +1 -1
  256. package/server/public/assets/NOTICES +281 -1
  257. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  258. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  259. package/server/public/canvaskit/wimp.wasm +0 -0
  260. package/server/public/flutter_bootstrap.js +2 -2
  261. package/server/public/main.dart.js +86707 -82621
  262. package/server/routes/auth.js +26 -5
  263. package/server/routes/integrations.js +108 -1
  264. package/server/routes/memory.js +11 -2
  265. package/server/routes/settings.js +84 -2
  266. package/server/routes/wearable.js +67 -0
  267. package/server/services/ai/engine.js +3 -2
  268. package/server/services/ai/models.js +30 -0
  269. package/server/services/ai/providers/githubCopilot.js +97 -0
  270. package/server/services/ai/providers/openai.js +2 -1
  271. package/server/services/ai/providers/openaiCodex.js +31 -0
  272. package/server/services/ai/settings.js +20 -0
  273. package/server/services/ai/toolSelector.js +54 -1
  274. package/server/services/ai/tools.js +88 -5
  275. package/server/services/desktop/screenRecorder.js +65 -9
  276. package/server/services/integrations/env.js +5 -0
  277. package/server/services/integrations/figma/provider.js +1 -0
  278. package/server/services/integrations/github/common.js +106 -0
  279. package/server/services/integrations/github/provider.js +499 -0
  280. package/server/services/integrations/github/repos.js +1124 -0
  281. package/server/services/integrations/google/provider.js +1 -0
  282. package/server/services/integrations/manager.js +88 -12
  283. package/server/services/integrations/microsoft/provider.js +1 -0
  284. package/server/services/integrations/oauth_provider.js +25 -8
  285. package/server/services/integrations/provider_config_store.js +85 -0
  286. package/server/services/integrations/registry.js +4 -2
  287. package/server/services/integrations/spotify/provider.js +1 -0
  288. package/server/services/integrations/trello/provider.js +842 -0
  289. package/server/services/manager.js +46 -1
  290. package/server/services/mcp/client.js +120 -23
  291. package/server/services/memory/manager.js +39 -2
  292. package/server/services/messaging/access_policy.js +10 -0
  293. package/server/services/messaging/manager.js +49 -0
  294. package/server/services/messaging/meshtastic.js +277 -0
  295. package/server/services/messaging/meshtastic_env.js +40 -0
  296. package/server/services/messaging/meshtastic_protocol.js +530 -0
  297. package/server/services/messaging/meshtastic_protocol.test.js +33 -0
  298. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  299. package/server/services/tasks/runtime.js +1 -1
  300. package/server/services/voice/openaiClient.js +4 -1
  301. package/server/services/voice/openaiSpeech.js +6 -1
  302. package/server/services/voice/providers.js +144 -15
  303. package/server/services/voice/runtimeManager.js +156 -19
  304. package/server/services/voice/turnRunner.js +29 -9
  305. package/server/services/wearable/firmware_manifest.js +353 -0
  306. package/server/services/wearable/gateway.js +350 -0
  307. package/server/services/wearable/protocol.js +45 -0
  308. package/server/services/wearable/service.js +240 -0
  309. package/server/services/widgets/service.js +3 -0
  310. package/server/utils/local_secrets.js +56 -0
  311. package/server/utils/logger.js +37 -9
  312. package/server/services/integrations/home_assistant/provider.js +0 -350
@@ -0,0 +1,2134 @@
1
+ part of 'main.dart';
2
+
3
+ class SplashView extends StatelessWidget {
4
+ const SplashView({super.key});
5
+
6
+ @override
7
+ Widget build(BuildContext context) {
8
+ return DecoratedBox(
9
+ decoration: BoxDecoration(
10
+ gradient: RadialGradient(
11
+ center: Alignment(-0.4, -0.6),
12
+ radius: 1.3,
13
+ colors: <Color>[_accent, _bgSecondary, _bgPrimary],
14
+ ),
15
+ ),
16
+ child: const Scaffold(
17
+ backgroundColor: Colors.transparent,
18
+ body: Center(
19
+ child: Column(
20
+ mainAxisSize: MainAxisSize.min,
21
+ children: <Widget>[
22
+ _BrandLockup(logoSize: 52),
23
+ SizedBox(height: 18),
24
+ CircularProgressIndicator(),
25
+ SizedBox(height: 16),
26
+ Text('Loading NeoOS'),
27
+ ],
28
+ ),
29
+ ),
30
+ ),
31
+ );
32
+ }
33
+ }
34
+
35
+ class BackendSetupView extends StatefulWidget {
36
+ const BackendSetupView({super.key, required this.controller});
37
+
38
+ final NeoAgentController controller;
39
+
40
+ @override
41
+ State<BackendSetupView> createState() => _BackendSetupViewState();
42
+ }
43
+
44
+ class _BackendSetupViewState extends State<BackendSetupView> {
45
+ late final TextEditingController _backendUrlController;
46
+
47
+ @override
48
+ void initState() {
49
+ super.initState();
50
+ _backendUrlController = TextEditingController(
51
+ text: widget.controller.backendUrl,
52
+ );
53
+ }
54
+
55
+ @override
56
+ void dispose() {
57
+ _backendUrlController.dispose();
58
+ super.dispose();
59
+ }
60
+
61
+ Future<void> _submit() async {
62
+ await widget.controller.saveBackendUrl(_backendUrlController.text);
63
+ }
64
+
65
+ @override
66
+ Widget build(BuildContext context) {
67
+ final controller = widget.controller;
68
+ return _AmbientBackdrop(
69
+ child: Scaffold(
70
+ backgroundColor: Colors.transparent,
71
+ body: SafeArea(
72
+ child: Center(
73
+ child: SingleChildScrollView(
74
+ padding: const EdgeInsets.all(24),
75
+ child: ConstrainedBox(
76
+ constraints: const BoxConstraints(maxWidth: 560),
77
+ child: _EntranceMotion(
78
+ child: _GlassSurface(
79
+ borderRadius: BorderRadius.circular(34),
80
+ blurSigma: 28,
81
+ boxShadow: _softPanelShadow,
82
+ overlayGradient: _panelGradient,
83
+ fillColor: _glassFill,
84
+ child: Padding(
85
+ padding: const EdgeInsets.fromLTRB(34, 32, 34, 30),
86
+ child: Column(
87
+ crossAxisAlignment: CrossAxisAlignment.start,
88
+ children: <Widget>[
89
+ const _BrandLockup(logoSize: 60),
90
+ const SizedBox(height: 22),
91
+ Text(
92
+ 'FIRST-RUN SETUP',
93
+ style: _sectionEyebrowStyle(),
94
+ ),
95
+ const SizedBox(height: 10),
96
+ Text(
97
+ 'Connect this build to your NeoAgent backend',
98
+ style: _displayTitleStyle(34),
99
+ ),
100
+ const SizedBox(height: 12),
101
+ Text(
102
+ 'This build was not bundled with a backend endpoint. Enter your NeoAgent server URL once and the app will store it locally for future launches.',
103
+ style: TextStyle(
104
+ color: _textSecondary,
105
+ height: 1.55,
106
+ ),
107
+ ),
108
+ const SizedBox(height: 24),
109
+ TextField(
110
+ controller: _backendUrlController,
111
+ keyboardType: TextInputType.url,
112
+ textInputAction: TextInputAction.done,
113
+ onSubmitted: (_) => _submit(),
114
+ decoration: const InputDecoration(
115
+ labelText: 'Backend URL',
116
+ hintText: 'https://neoagent.example.com',
117
+ prefixIcon: Icon(Icons.cloud_outlined),
118
+ ),
119
+ ),
120
+ const SizedBox(height: 14),
121
+ Container(
122
+ width: double.infinity,
123
+ padding: const EdgeInsets.all(14),
124
+ decoration: BoxDecoration(
125
+ color: _bgSecondary.withValues(alpha: 0.72),
126
+ borderRadius: BorderRadius.circular(18),
127
+ border: Border.all(color: _borderLight),
128
+ ),
129
+ child: Row(
130
+ crossAxisAlignment: CrossAxisAlignment.start,
131
+ children: <Widget>[
132
+ Icon(
133
+ Icons.privacy_tip_outlined,
134
+ color: _accent,
135
+ size: 18,
136
+ ),
137
+ const SizedBox(width: 10),
138
+ Expanded(
139
+ child: Text(
140
+ 'Use your hosted NeoAgent URL. If you enter a hostname without a scheme, the app will infer `https://` for remote hosts and `http://` for local addresses.',
141
+ style: TextStyle(
142
+ color: _textSecondary,
143
+ height: 1.45,
144
+ ),
145
+ ),
146
+ ),
147
+ ],
148
+ ),
149
+ ),
150
+ if (controller.errorMessage
151
+ case final message?) ...<Widget>[
152
+ const SizedBox(height: 16),
153
+ _InlineError(message: message),
154
+ ],
155
+ const SizedBox(height: 22),
156
+ SizedBox(
157
+ width: double.infinity,
158
+ child: FilledButton.icon(
159
+ onPressed: controller.isSavingBackendUrl
160
+ ? null
161
+ : _submit,
162
+ style: FilledButton.styleFrom(
163
+ backgroundColor: _accent,
164
+ padding: const EdgeInsets.symmetric(
165
+ vertical: 16,
166
+ ),
167
+ ),
168
+ icon: controller.isSavingBackendUrl
169
+ ? const SizedBox.square(
170
+ dimension: 18,
171
+ child: CircularProgressIndicator(
172
+ strokeWidth: 2,
173
+ color: Colors.white,
174
+ ),
175
+ )
176
+ : const Icon(Icons.arrow_forward_rounded),
177
+ label: Text(
178
+ controller.isSavingBackendUrl
179
+ ? 'Connecting...'
180
+ : 'Connect Backend',
181
+ ),
182
+ ),
183
+ ),
184
+ ],
185
+ ),
186
+ ),
187
+ ),
188
+ ),
189
+ ),
190
+ ),
191
+ ),
192
+ ),
193
+ ),
194
+ );
195
+ }
196
+ }
197
+
198
+ class AuthView extends StatefulWidget {
199
+ const AuthView({super.key, required this.controller});
200
+
201
+ final NeoAgentController controller;
202
+
203
+ @override
204
+ State<AuthView> createState() => _AuthViewState();
205
+ }
206
+
207
+ class _AuthViewState extends State<AuthView> {
208
+ late final TextEditingController _usernameController;
209
+ late final TextEditingController _emailController;
210
+ late final TextEditingController _passwordController;
211
+ late final TextEditingController _confirmPasswordController;
212
+ late final TextEditingController _twoFactorController;
213
+ bool _registerMode = false;
214
+ bool _qrAutoRequestedForVisibleMode = false;
215
+
216
+ @override
217
+ void initState() {
218
+ super.initState();
219
+ _usernameController = TextEditingController(
220
+ text: widget.controller.username,
221
+ );
222
+ _emailController = TextEditingController(
223
+ text: widget.controller.user?['email']?.toString() ?? '',
224
+ );
225
+ _passwordController = TextEditingController(
226
+ text: widget.controller.password,
227
+ );
228
+ _confirmPasswordController = TextEditingController();
229
+ _twoFactorController = TextEditingController();
230
+ }
231
+
232
+ @override
233
+ void dispose() {
234
+ _usernameController.dispose();
235
+ _emailController.dispose();
236
+ _passwordController.dispose();
237
+ _confirmPasswordController.dispose();
238
+ _twoFactorController.dispose();
239
+ super.dispose();
240
+ }
241
+
242
+ Future<void> _showForgotPasswordDialog() async {
243
+ final accountController = TextEditingController(
244
+ text: _usernameController.text.trim(),
245
+ );
246
+ String? inlineError;
247
+ try {
248
+ await showDialog<void>(
249
+ context: context,
250
+ builder: (dialogContext) {
251
+ return StatefulBuilder(
252
+ builder: (context, setDialogState) {
253
+ return AlertDialog(
254
+ backgroundColor: _bgCard,
255
+ title: Text('Reset password'),
256
+ content: SizedBox(
257
+ width: 420,
258
+ child: Column(
259
+ mainAxisSize: MainAxisSize.min,
260
+ crossAxisAlignment: CrossAxisAlignment.stretch,
261
+ children: <Widget>[
262
+ Text(
263
+ 'Enter your username or account email. NeoOS will send a reset link if it can match the account.',
264
+ style: TextStyle(color: _textSecondary, height: 1.45),
265
+ ),
266
+ const SizedBox(height: 16),
267
+ TextField(
268
+ controller: accountController,
269
+ keyboardType: TextInputType.emailAddress,
270
+ decoration: const InputDecoration(
271
+ labelText: 'Username or email',
272
+ ),
273
+ ),
274
+ if (inlineError != null) ...<Widget>[
275
+ const SizedBox(height: 12),
276
+ _InlineError(message: inlineError!),
277
+ ],
278
+ ],
279
+ ),
280
+ ),
281
+ actions: <Widget>[
282
+ TextButton(
283
+ onPressed: widget.controller.isAuthenticating
284
+ ? null
285
+ : () => Navigator.of(dialogContext).pop(),
286
+ child: Text('Cancel'),
287
+ ),
288
+ FilledButton(
289
+ onPressed: widget.controller.isAuthenticating
290
+ ? null
291
+ : () async {
292
+ final account = accountController.text.trim();
293
+ if (account.isEmpty) {
294
+ setDialogState(() {
295
+ inlineError = 'Enter your username or email.';
296
+ });
297
+ return;
298
+ }
299
+ final sent = await widget.controller
300
+ .requestPasswordReset(account);
301
+ if (sent && dialogContext.mounted) {
302
+ Navigator.of(dialogContext).pop();
303
+ }
304
+ },
305
+ child: widget.controller.isAuthenticating
306
+ ? const SizedBox.square(
307
+ dimension: 16,
308
+ child: CircularProgressIndicator(strokeWidth: 2),
309
+ )
310
+ : Text('Send link'),
311
+ ),
312
+ ],
313
+ );
314
+ },
315
+ );
316
+ },
317
+ );
318
+ } finally {
319
+ accountController.dispose();
320
+ }
321
+ }
322
+
323
+ Future<void> _showQrLoginDialog() async {
324
+ _qrAutoRequestedForVisibleMode = true;
325
+ await widget.controller.prepareQrLoginChallenge();
326
+ if (!mounted) {
327
+ return;
328
+ }
329
+ final controller = widget.controller;
330
+ await showDialog<void>(
331
+ context: context,
332
+ builder: (dialogContext) {
333
+ return StatefulBuilder(
334
+ builder: (context, setDialogState) {
335
+ final challenge = controller.qrLoginChallenge;
336
+ final canShowQr =
337
+ challenge?.isUsable == true && !(challenge?.isExpired ?? true);
338
+ final countdown = challenge?.secondsRemaining ?? 0;
339
+
340
+ Widget buildQrSurface() {
341
+ return Container(
342
+ width: double.infinity,
343
+ padding: const EdgeInsets.all(18),
344
+ decoration: BoxDecoration(
345
+ color: Colors.white,
346
+ borderRadius: BorderRadius.circular(24),
347
+ ),
348
+ child: AspectRatio(
349
+ aspectRatio: 1,
350
+ child: Center(
351
+ child: canShowQr
352
+ ? QrImageView(
353
+ data: challenge!.qrPayload,
354
+ version: QrVersions.auto,
355
+ eyeStyle: const QrEyeStyle(
356
+ eyeShape: QrEyeShape.square,
357
+ color: Color(0xFF04111D),
358
+ ),
359
+ dataModuleStyle: const QrDataModuleStyle(
360
+ dataModuleShape: QrDataModuleShape.square,
361
+ color: Color(0xFF04111D),
362
+ ),
363
+ )
364
+ : controller.isPreparingQrLogin
365
+ ? const SizedBox.square(
366
+ dimension: 40,
367
+ child: CircularProgressIndicator(strokeWidth: 3),
368
+ )
369
+ : Icon(
370
+ Icons.qr_code_2_rounded,
371
+ size: 84,
372
+ color: _textMuted,
373
+ ),
374
+ ),
375
+ ),
376
+ );
377
+ }
378
+
379
+ return AlertDialog(
380
+ backgroundColor: _bgCard,
381
+ title: const Text('Pair with QR code'),
382
+ content: SizedBox(
383
+ width: 360,
384
+ child: Column(
385
+ mainAxisSize: MainAxisSize.min,
386
+ crossAxisAlignment: CrossAxisAlignment.stretch,
387
+ children: <Widget>[
388
+ Text(
389
+ 'Open Account settings on a signed-in Android device, scan this code, and approve the login.',
390
+ style: TextStyle(color: _textSecondary, height: 1.45),
391
+ ),
392
+ const SizedBox(height: 16),
393
+ buildQrSurface(),
394
+ const SizedBox(height: 14),
395
+ _InfoChip(
396
+ icon: Icons.timer_outlined,
397
+ label: canShowQr
398
+ ? 'Refreshes in ${countdown}s'
399
+ : 'Waiting for code',
400
+ ),
401
+ if (controller.qrLoginErrorMessage != null) ...<Widget>[
402
+ const SizedBox(height: 12),
403
+ _InlineError(message: controller.qrLoginErrorMessage!),
404
+ ],
405
+ ],
406
+ ),
407
+ ),
408
+ actions: <Widget>[
409
+ TextButton(
410
+ onPressed: controller.isPreparingQrLogin
411
+ ? null
412
+ : () async {
413
+ _qrAutoRequestedForVisibleMode = true;
414
+ await widget.controller.prepareQrLoginChallenge(
415
+ force: true,
416
+ );
417
+ if (mounted) {
418
+ setDialogState(() {});
419
+ }
420
+ },
421
+ child: const Text('Refresh code'),
422
+ ),
423
+ FilledButton(
424
+ onPressed: () => Navigator.of(dialogContext).pop(),
425
+ child: const Text('Close'),
426
+ ),
427
+ ],
428
+ );
429
+ },
430
+ );
431
+ },
432
+ );
433
+ }
434
+
435
+ void _ensureQrLoginChallenge({bool force = false}) {
436
+ if (!mounted) return;
437
+ if (force) {
438
+ _qrAutoRequestedForVisibleMode = true;
439
+ }
440
+ unawaited(widget.controller.prepareQrLoginChallenge(force: force));
441
+ }
442
+
443
+ Widget _buildAuthFormPane({
444
+ required NeoAgentController controller,
445
+ required List<AuthProviderCatalogItem> availableProviders,
446
+ required bool awaitingTwoFactor,
447
+ required bool showRegisterToggle,
448
+ required String title,
449
+ required String subtitle,
450
+ }) {
451
+ return Column(
452
+ mainAxisSize: MainAxisSize.min,
453
+ crossAxisAlignment: CrossAxisAlignment.stretch,
454
+ children: <Widget>[
455
+ Column(children: <Widget>[const _BrandLockup(logoSize: 58)]),
456
+ const SizedBox(height: 26),
457
+ Text(
458
+ awaitingTwoFactor ? 'Verification' : title.toUpperCase(),
459
+ style: _sectionEyebrowStyle(),
460
+ ),
461
+ const SizedBox(height: 8),
462
+ Text(
463
+ awaitingTwoFactor ? 'Enter 2FA code' : title,
464
+ style: _displayTitleStyle(30),
465
+ ),
466
+ const SizedBox(height: 8),
467
+ Text(
468
+ awaitingTwoFactor
469
+ ? 'Open your authenticator app and enter the current NeoOS code.'
470
+ : subtitle,
471
+ style: TextStyle(color: _textSecondary, height: 1.5),
472
+ ),
473
+ const SizedBox(height: 20),
474
+ if (controller.errorMessage != null) ...<Widget>[
475
+ _InlineError(message: controller.errorMessage!),
476
+ const SizedBox(height: 16),
477
+ ],
478
+ if (controller.authInfoMessage != null) ...<Widget>[
479
+ _InlineSuccess(message: controller.authInfoMessage!),
480
+ const SizedBox(height: 24),
481
+ ],
482
+ if (awaitingTwoFactor) ...<Widget>[
483
+ TextField(
484
+ controller: _twoFactorController,
485
+ keyboardType: TextInputType.number,
486
+ decoration: const InputDecoration(
487
+ labelText: '2FA or recovery code',
488
+ ),
489
+ ),
490
+ ] else ...<Widget>[
491
+ TextField(
492
+ controller: _usernameController,
493
+ onChanged: (_) => setState(() {}),
494
+ decoration: const InputDecoration(labelText: 'Username'),
495
+ ),
496
+ const SizedBox(height: 14),
497
+ TextField(
498
+ controller: _passwordController,
499
+ onChanged: (_) => setState(() {}),
500
+ obscureText: true,
501
+ decoration: const InputDecoration(labelText: 'Password'),
502
+ ),
503
+ if (_registerMode) ...<Widget>[
504
+ const SizedBox(height: 10),
505
+ _PasswordStrengthIndicator(
506
+ info: _passwordStrengthInfo(
507
+ password: _passwordController.text,
508
+ username: _usernameController.text,
509
+ email: _emailController.text,
510
+ ),
511
+ ),
512
+ const SizedBox(height: 14),
513
+ TextField(
514
+ controller: _emailController,
515
+ onChanged: (_) => setState(() {}),
516
+ keyboardType: TextInputType.emailAddress,
517
+ autofillHints: const <String>[AutofillHints.email],
518
+ decoration: const InputDecoration(labelText: 'Email'),
519
+ ),
520
+ const SizedBox(height: 14),
521
+ TextField(
522
+ controller: _confirmPasswordController,
523
+ obscureText: true,
524
+ decoration: const InputDecoration(labelText: 'Confirm Password'),
525
+ ),
526
+ ],
527
+ ],
528
+ const SizedBox(height: 22),
529
+ FilledButton(
530
+ onPressed: controller.isAuthenticating
531
+ ? null
532
+ : () async {
533
+ if (awaitingTwoFactor) {
534
+ await controller.completeTwoFactorLogin(
535
+ code: _twoFactorController.text,
536
+ );
537
+ return;
538
+ }
539
+ if (_registerMode &&
540
+ _passwordController.text !=
541
+ _confirmPasswordController.text) {
542
+ widget.controller.showInlineError(
543
+ 'Passwords do not match.',
544
+ );
545
+ return;
546
+ }
547
+ if (_registerMode) {
548
+ await controller.register(
549
+ username: _usernameController.text,
550
+ email: _emailController.text,
551
+ password: _passwordController.text,
552
+ );
553
+ } else {
554
+ await controller.login(
555
+ username: _usernameController.text,
556
+ password: _passwordController.text,
557
+ );
558
+ }
559
+ },
560
+ style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(58)),
561
+ child: controller.isAuthenticating
562
+ ? const SizedBox.square(
563
+ dimension: 20,
564
+ child: CircularProgressIndicator(
565
+ strokeWidth: 2,
566
+ color: Colors.white,
567
+ ),
568
+ )
569
+ : Text(
570
+ awaitingTwoFactor
571
+ ? 'Verify'
572
+ : (_registerMode ? 'Create account' : 'Sign in'),
573
+ ),
574
+ ),
575
+ if (awaitingTwoFactor) ...<Widget>[
576
+ const SizedBox(height: 12),
577
+ TextButton(
578
+ onPressed: controller.isAuthenticating
579
+ ? null
580
+ : controller.cancelTwoFactorLogin,
581
+ child: const Text('Back to sign in'),
582
+ ),
583
+ ] else ...<Widget>[
584
+ if (availableProviders.isNotEmpty) ...<Widget>[
585
+ const SizedBox(height: 16),
586
+ Row(
587
+ children: <Widget>[
588
+ Expanded(child: Divider(color: _borderLight)),
589
+ Padding(
590
+ padding: const EdgeInsets.symmetric(horizontal: 10),
591
+ child: Text(
592
+ 'or continue with',
593
+ style: TextStyle(color: _textSecondary, fontSize: 12),
594
+ ),
595
+ ),
596
+ Expanded(child: Divider(color: _borderLight)),
597
+ ],
598
+ ),
599
+ const SizedBox(height: 14),
600
+ ...availableProviders.map(
601
+ (provider) => Padding(
602
+ padding: const EdgeInsets.only(bottom: 10),
603
+ child: OutlinedButton.icon(
604
+ onPressed: controller.isAuthenticating
605
+ ? null
606
+ : () => controller.authenticateWithProvider(
607
+ provider: provider.id,
608
+ register: _registerMode,
609
+ ),
610
+ icon: provider.icon == 'google'
611
+ ? const Text(
612
+ 'G',
613
+ style: TextStyle(
614
+ fontSize: 18,
615
+ fontWeight: FontWeight.w700,
616
+ color: Color(0xFF4285F4),
617
+ ),
618
+ )
619
+ : const Icon(Icons.link),
620
+ label: Text(
621
+ _registerMode
622
+ ? 'Register with ${provider.label}'
623
+ : 'Sign in with ${provider.label}',
624
+ ),
625
+ style: OutlinedButton.styleFrom(
626
+ minimumSize: const Size.fromHeight(54),
627
+ backgroundColor: _bgPrimary.withValues(alpha: 0.18),
628
+ ),
629
+ ),
630
+ ),
631
+ ),
632
+ ],
633
+ if (!_registerMode && controller.serviceEmailConfigured) ...<Widget>[
634
+ const SizedBox(height: 12),
635
+ TextButton(
636
+ onPressed: controller.isAuthenticating
637
+ ? null
638
+ : _showForgotPasswordDialog,
639
+ child: const Text('Forgot password?'),
640
+ ),
641
+ if (!showRegisterToggle) const SizedBox(height: 12),
642
+ ],
643
+ if (showRegisterToggle) ...<Widget>[
644
+ const SizedBox(height: 12),
645
+ TextButton(
646
+ onPressed: controller.isAuthenticating
647
+ ? null
648
+ : () {
649
+ setState(() {
650
+ _registerMode = !_registerMode;
651
+ });
652
+ if (!_registerMode) {
653
+ _qrAutoRequestedForVisibleMode = false;
654
+ _ensureQrLoginChallenge(force: true);
655
+ }
656
+ },
657
+ child: Text(
658
+ _registerMode
659
+ ? 'Already have an account? Sign in'
660
+ : 'Need a new account? Register',
661
+ ),
662
+ ),
663
+ ],
664
+ ],
665
+ ],
666
+ );
667
+ }
668
+
669
+ Widget _buildQrLoginPane(NeoAgentController controller) {
670
+ final challenge = controller.qrLoginChallenge;
671
+ final countdown = challenge?.secondsRemaining ?? 0;
672
+ final canShowQr =
673
+ challenge?.isUsable == true && !(challenge?.isExpired ?? true);
674
+ return LayoutBuilder(
675
+ builder: (context, constraints) {
676
+ final compact = constraints.maxWidth < 420;
677
+ final narrow = constraints.maxWidth < 520;
678
+ final showInlineQr = !narrow;
679
+ final panelPadding = compact ? 18.0 : 24.0;
680
+ final qrShellPadding = compact ? 14.0 : 18.0;
681
+ final qrCardPadding = compact ? 14.0 : 18.0;
682
+ final titleSize = compact ? 22.0 : 28.0;
683
+ final titleAlignment = compact ? TextAlign.center : TextAlign.left;
684
+ final contentAlignment = compact
685
+ ? CrossAxisAlignment.center
686
+ : CrossAxisAlignment.start;
687
+
688
+ Widget buildInfoSection() {
689
+ return _InfoChip(
690
+ icon: Icons.timer_outlined,
691
+ label: canShowQr
692
+ ? 'Refreshes in ${countdown}s'
693
+ : 'Waiting for code',
694
+ );
695
+ }
696
+
697
+ return Container(
698
+ decoration: BoxDecoration(
699
+ borderRadius: BorderRadius.circular(compact ? 24 : 28),
700
+ gradient: LinearGradient(
701
+ begin: Alignment.topLeft,
702
+ end: Alignment.bottomRight,
703
+ colors: <Color>[
704
+ const Color(0xFF0A1D2E),
705
+ _bgSecondary.withValues(alpha: 0.96),
706
+ const Color(0xFF112B43),
707
+ ],
708
+ ),
709
+ border: Border.all(color: _borderLight.withValues(alpha: 0.45)),
710
+ boxShadow: <BoxShadow>[
711
+ BoxShadow(
712
+ color: const Color(0xFF6EDBFF).withValues(alpha: 0.12),
713
+ blurRadius: 36,
714
+ spreadRadius: 2,
715
+ ),
716
+ ],
717
+ ),
718
+ child: Stack(
719
+ children: <Widget>[
720
+ Positioned(
721
+ top: compact ? -18 : -24,
722
+ right: compact ? -24 : -12,
723
+ child: IgnorePointer(
724
+ child: Container(
725
+ width: compact ? 86 : 120,
726
+ height: compact ? 86 : 120,
727
+ decoration: BoxDecoration(
728
+ shape: BoxShape.circle,
729
+ color: const Color(0xFF6EDBFF).withValues(alpha: 0.12),
730
+ ),
731
+ ),
732
+ ),
733
+ ),
734
+ Positioned(
735
+ bottom: compact ? -34 : -36,
736
+ left: compact ? -28 : -18,
737
+ child: IgnorePointer(
738
+ child: Container(
739
+ width: compact ? 110 : 140,
740
+ height: compact ? 110 : 140,
741
+ decoration: BoxDecoration(
742
+ shape: BoxShape.circle,
743
+ color: const Color(0xFF58E0A2).withValues(alpha: 0.10),
744
+ ),
745
+ ),
746
+ ),
747
+ ),
748
+ Padding(
749
+ padding: EdgeInsets.all(panelPadding),
750
+ child: Column(
751
+ crossAxisAlignment: contentAlignment,
752
+ children: <Widget>[
753
+ Text(
754
+ 'Scan with NeoOS on your phone',
755
+ textAlign: titleAlignment,
756
+ style: GoogleFonts.spaceGrotesk(
757
+ fontSize: titleSize,
758
+ fontWeight: FontWeight.w700,
759
+ letterSpacing: compact ? -0.3 : -0.6,
760
+ color: Colors.white,
761
+ height: compact ? 1.05 : null,
762
+ ),
763
+ ),
764
+ const SizedBox(height: 10),
765
+ ConstrainedBox(
766
+ constraints: const BoxConstraints(maxWidth: 440),
767
+ child: Text(
768
+ 'On a signed-in Android device, open Account settings, scan this code, and approve the login.',
769
+ textAlign: titleAlignment,
770
+ style: TextStyle(
771
+ color: Colors.white.withValues(alpha: 0.78),
772
+ height: 1.5,
773
+ ),
774
+ ),
775
+ ),
776
+ SizedBox(height: compact ? 18 : 22),
777
+ Container(
778
+ width: double.infinity,
779
+ padding: EdgeInsets.all(qrShellPadding),
780
+ decoration: BoxDecoration(
781
+ color: Colors.white.withValues(alpha: 0.08),
782
+ borderRadius: BorderRadius.circular(compact ? 20 : 24),
783
+ border: Border.all(
784
+ color: Colors.white.withValues(alpha: 0.12),
785
+ ),
786
+ ),
787
+ child: Column(
788
+ children: <Widget>[
789
+ if (showInlineQr)
790
+ Container(
791
+ width: double.infinity,
792
+ padding: EdgeInsets.all(qrCardPadding),
793
+ decoration: BoxDecoration(
794
+ color: Colors.white,
795
+ borderRadius: BorderRadius.circular(
796
+ compact ? 18 : 22,
797
+ ),
798
+ boxShadow: <BoxShadow>[
799
+ BoxShadow(
800
+ color: Colors.black.withValues(alpha: 0.12),
801
+ blurRadius: 26,
802
+ offset: const Offset(0, 10),
803
+ ),
804
+ ],
805
+ ),
806
+ child: AspectRatio(
807
+ aspectRatio: 1,
808
+ child: Center(
809
+ child: canShowQr
810
+ ? QrImageView(
811
+ data: challenge!.qrPayload,
812
+ version: QrVersions.auto,
813
+ eyeStyle: const QrEyeStyle(
814
+ eyeShape: QrEyeShape.square,
815
+ color: Color(0xFF04111D),
816
+ ),
817
+ dataModuleStyle:
818
+ const QrDataModuleStyle(
819
+ dataModuleShape:
820
+ QrDataModuleShape.square,
821
+ color: Color(0xFF04111D),
822
+ ),
823
+ )
824
+ : controller.isPreparingQrLogin
825
+ ? const SizedBox.square(
826
+ dimension: 40,
827
+ child: CircularProgressIndicator(
828
+ strokeWidth: 3,
829
+ ),
830
+ )
831
+ : Icon(
832
+ Icons.qr_code_2_rounded,
833
+ size: narrow ? 72 : 84,
834
+ color: _textMuted,
835
+ ),
836
+ ),
837
+ ),
838
+ )
839
+ else
840
+ SizedBox(
841
+ width: double.infinity,
842
+ child: FilledButton.icon(
843
+ onPressed: controller.isPreparingQrLogin
844
+ ? null
845
+ : _showQrLoginDialog,
846
+ style: FilledButton.styleFrom(
847
+ minimumSize: const Size.fromHeight(56),
848
+ backgroundColor: Colors.white,
849
+ foregroundColor: const Color(0xFF04111D),
850
+ ),
851
+ icon: controller.isPreparingQrLogin
852
+ ? const SizedBox.square(
853
+ dimension: 16,
854
+ child: CircularProgressIndicator(
855
+ strokeWidth: 2,
856
+ color: Color(0xFF04111D),
857
+ ),
858
+ )
859
+ : const Icon(Icons.qr_code_2_rounded),
860
+ label: Text(
861
+ canShowQr
862
+ ? 'Show QR code'
863
+ : 'Prepare QR code',
864
+ ),
865
+ ),
866
+ ),
867
+ const SizedBox(height: 16),
868
+ buildInfoSection(),
869
+ ],
870
+ ),
871
+ ),
872
+ if (controller.qrLoginErrorMessage != null) ...<Widget>[
873
+ const SizedBox(height: 14),
874
+ _InlineError(message: controller.qrLoginErrorMessage!),
875
+ ],
876
+ const SizedBox(height: 16),
877
+ Text(
878
+ 'Approval stays inside your authenticated mobile session, and each code expires automatically after a short window.',
879
+ textAlign: titleAlignment,
880
+ style: TextStyle(
881
+ color: Colors.white.withValues(alpha: 0.68),
882
+ height: 1.45,
883
+ ),
884
+ ),
885
+ const SizedBox(height: 18),
886
+ SizedBox(
887
+ width: double.infinity,
888
+ child: OutlinedButton.icon(
889
+ onPressed: controller.isPreparingQrLogin
890
+ ? null
891
+ : () => _ensureQrLoginChallenge(force: true),
892
+ icon: controller.isPreparingQrLogin
893
+ ? const SizedBox.square(
894
+ dimension: 16,
895
+ child: CircularProgressIndicator(
896
+ strokeWidth: 2,
897
+ ),
898
+ )
899
+ : const Icon(Icons.refresh_rounded),
900
+ label: const Text('Refresh code'),
901
+ style: OutlinedButton.styleFrom(
902
+ minimumSize: const Size.fromHeight(52),
903
+ foregroundColor: Colors.white,
904
+ side: BorderSide(
905
+ color: Colors.white.withValues(alpha: 0.18),
906
+ ),
907
+ ),
908
+ ),
909
+ ),
910
+ ],
911
+ ),
912
+ ),
913
+ ],
914
+ ),
915
+ );
916
+ },
917
+ );
918
+ }
919
+
920
+ @override
921
+ Widget build(BuildContext context) {
922
+ final controller = widget.controller;
923
+ final availableProviders = controller.authProviders
924
+ .where((provider) => provider.configured)
925
+ .toList();
926
+ if (!controller.hasUser) {
927
+ _registerMode = true;
928
+ }
929
+
930
+ final title = _registerMode
931
+ ? (controller.hasUser ? 'Create account' : 'Create the first account')
932
+ : 'Sign in';
933
+ final subtitle = _registerMode
934
+ ? (controller.hasUser
935
+ ? 'Create another NeoOS account.'
936
+ : 'This account will unlock NeoOS on this machine.')
937
+ : 'Enter your NeoOS account details.';
938
+ final awaitingTwoFactor = controller.isAwaitingTwoFactor;
939
+ final showRegisterToggle =
940
+ controller.registrationOpen && controller.hasUser;
941
+ final showQrLogin = !awaitingTwoFactor && !_registerMode;
942
+
943
+ if (showQrLogin &&
944
+ !controller.isPreparingQrLogin &&
945
+ !controller.isAuthenticated &&
946
+ !_qrAutoRequestedForVisibleMode) {
947
+ _qrAutoRequestedForVisibleMode = true;
948
+ WidgetsBinding.instance.addPostFrameCallback((_) {
949
+ _ensureQrLoginChallenge();
950
+ });
951
+ }
952
+ if (!showQrLogin) {
953
+ _qrAutoRequestedForVisibleMode = false;
954
+ }
955
+
956
+ return _AmbientBackdrop(
957
+ child: Scaffold(
958
+ backgroundColor: Colors.transparent,
959
+ body: SafeArea(
960
+ child: LayoutBuilder(
961
+ builder: (context, viewportConstraints) {
962
+ return SingleChildScrollView(
963
+ child: ConstrainedBox(
964
+ constraints: BoxConstraints(
965
+ minHeight: viewportConstraints.maxHeight,
966
+ ),
967
+ child: Padding(
968
+ padding: EdgeInsets.all(
969
+ viewportConstraints.maxWidth < 480 ? 14 : 24,
970
+ ),
971
+ child: Center(
972
+ child: ConstrainedBox(
973
+ constraints: BoxConstraints(
974
+ maxWidth: showQrLogin ? 980 : 468,
975
+ ),
976
+ child: _EntranceMotion(
977
+ child: _GlassSurface(
978
+ borderRadius: BorderRadius.circular(32),
979
+ blurSigma: 28,
980
+ boxShadow: _softPanelShadow,
981
+ overlayGradient: _panelGradient,
982
+ fillColor: _glassFill,
983
+ child: Padding(
984
+ padding: EdgeInsets.fromLTRB(
985
+ viewportConstraints.maxWidth < 480 ? 18 : 34,
986
+ viewportConstraints.maxWidth < 480 ? 20 : 30,
987
+ viewportConstraints.maxWidth < 480 ? 18 : 34,
988
+ viewportConstraints.maxWidth < 480 ? 20 : 30,
989
+ ),
990
+ child: LayoutBuilder(
991
+ builder: (context, panelConstraints) {
992
+ final useWideQrLayout =
993
+ showQrLogin &&
994
+ panelConstraints.maxWidth >= 820;
995
+ final formPane = _buildAuthFormPane(
996
+ controller: controller,
997
+ availableProviders: availableProviders,
998
+ awaitingTwoFactor: awaitingTwoFactor,
999
+ showRegisterToggle: showRegisterToggle,
1000
+ title: title,
1001
+ subtitle: subtitle,
1002
+ );
1003
+ if (!showQrLogin) {
1004
+ return formPane;
1005
+ }
1006
+ if (useWideQrLayout) {
1007
+ return Row(
1008
+ crossAxisAlignment:
1009
+ CrossAxisAlignment.start,
1010
+ children: <Widget>[
1011
+ Expanded(flex: 11, child: formPane),
1012
+ const SizedBox(width: 24),
1013
+ Expanded(
1014
+ flex: 10,
1015
+ child: _buildQrLoginPane(controller),
1016
+ ),
1017
+ ],
1018
+ );
1019
+ }
1020
+ return Column(
1021
+ mainAxisSize: MainAxisSize.min,
1022
+ crossAxisAlignment:
1023
+ CrossAxisAlignment.stretch,
1024
+ children: <Widget>[
1025
+ formPane,
1026
+ const SizedBox(height: 22),
1027
+ _buildQrLoginPane(controller),
1028
+ ],
1029
+ );
1030
+ },
1031
+ ),
1032
+ ),
1033
+ ),
1034
+ ),
1035
+ ),
1036
+ ),
1037
+ ),
1038
+ ),
1039
+ );
1040
+ },
1041
+ ),
1042
+ ),
1043
+ ),
1044
+ );
1045
+ }
1046
+ }
1047
+
1048
+ class HomeView extends StatefulWidget {
1049
+ const HomeView({super.key, required this.controller});
1050
+
1051
+ final NeoAgentController controller;
1052
+
1053
+ @override
1054
+ State<HomeView> createState() => _HomeViewState();
1055
+ }
1056
+
1057
+ class _HomeViewState extends State<HomeView> {
1058
+ bool _blockedDialogOpen = false;
1059
+ SidebarGroup? _expandedSidebarGroup;
1060
+ AppSection? _lastSelectedSection;
1061
+
1062
+ @override
1063
+ void initState() {
1064
+ super.initState();
1065
+
1066
+ // Initialize Proactive Context Features for mobile
1067
+ if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
1068
+ final backendUrl = widget.controller.backendUrl;
1069
+ final sessionCookie = widget.controller.sessionCookie?.trim() ?? '';
1070
+ final canInitializeMobileAutomation =
1071
+ backendUrl.trim().isNotEmpty && sessionCookie.isNotEmpty;
1072
+
1073
+ if (canInitializeMobileAutomation) {
1074
+ final locationService = LocationService();
1075
+
1076
+ locationService
1077
+ .initialize(context)
1078
+ .then((_) {
1079
+ if (mounted) {
1080
+ locationService.startGeofenceTracking(
1081
+ backendUrl,
1082
+ sessionCookie,
1083
+ );
1084
+ }
1085
+ })
1086
+ .catchError((error) {
1087
+ if (mounted) {
1088
+ debugPrint('LocationService initialization failed: $error');
1089
+ }
1090
+ });
1091
+
1092
+ if (Platform.isAndroid) {
1093
+ NotificationInterceptor().initialize(
1094
+ context,
1095
+ backendUrl,
1096
+ sessionCookie,
1097
+ );
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ _lastSelectedSection = widget.controller.selectedSection;
1103
+ _expandedSidebarGroup = _sidebarGroupForSection(
1104
+ widget.controller.selectedSection,
1105
+ );
1106
+ widget.controller.addListener(_handleControllerChanged);
1107
+ }
1108
+
1109
+ @override
1110
+ void didUpdateWidget(covariant HomeView oldWidget) {
1111
+ super.didUpdateWidget(oldWidget);
1112
+ if (oldWidget.controller != widget.controller) {
1113
+ oldWidget.controller.removeListener(_handleControllerChanged);
1114
+ widget.controller.addListener(_handleControllerChanged);
1115
+ _lastSelectedSection = widget.controller.selectedSection;
1116
+ _expandedSidebarGroup = _sidebarGroupForSection(
1117
+ widget.controller.selectedSection,
1118
+ );
1119
+ }
1120
+ }
1121
+
1122
+ SidebarGroup? _sidebarGroupForSection(AppSection section) {
1123
+ if (!_mainSections(widget.controller).contains(section)) {
1124
+ return null;
1125
+ }
1126
+ return section.group;
1127
+ }
1128
+
1129
+ void _handleControllerChanged() {
1130
+ if (!mounted) {
1131
+ return;
1132
+ }
1133
+ final nextSection = widget.controller.selectedSection;
1134
+ setState(() {
1135
+ if (_lastSelectedSection != nextSection) {
1136
+ final oldGroup = _lastSelectedSection == null
1137
+ ? null
1138
+ : _sidebarGroupForSection(_lastSelectedSection!);
1139
+ final nextGroup = _sidebarGroupForSection(nextSection);
1140
+ if (oldGroup != nextGroup) {
1141
+ _expandedSidebarGroup = nextGroup;
1142
+ }
1143
+ _lastSelectedSection = nextSection;
1144
+ }
1145
+ });
1146
+ }
1147
+
1148
+ void _toggleSidebarGroup(SidebarGroup group) {
1149
+ setState(() {
1150
+ _expandedSidebarGroup = _expandedSidebarGroup == group ? null : group;
1151
+ });
1152
+ }
1153
+
1154
+ @override
1155
+ void dispose() {
1156
+ widget.controller.removeListener(_handleControllerChanged);
1157
+ super.dispose();
1158
+ }
1159
+
1160
+ @override
1161
+ Widget build(BuildContext context) {
1162
+ final controller = widget.controller;
1163
+ final pendingBlockedSender = controller.pendingBlockedSenderNotice;
1164
+
1165
+ if (!_blockedDialogOpen && pendingBlockedSender != null) {
1166
+ WidgetsBinding.instance.addPostFrameCallback((_) {
1167
+ if (!mounted || _blockedDialogOpen) {
1168
+ return;
1169
+ }
1170
+ _showBlockedSenderDialog(pendingBlockedSender);
1171
+ });
1172
+ }
1173
+
1174
+ final wide = MediaQuery.sizeOf(context).width >= 1080;
1175
+
1176
+ if (wide) {
1177
+ return _AmbientBackdrop(
1178
+ child: Scaffold(
1179
+ backgroundColor: Colors.transparent,
1180
+ body: SafeArea(
1181
+ child: Padding(
1182
+ padding: const EdgeInsets.all(14),
1183
+ child: Row(
1184
+ children: <Widget>[
1185
+ _Sidebar(
1186
+ controller: controller,
1187
+ expandedGroup: _expandedSidebarGroup,
1188
+ onToggleGroup: _toggleSidebarGroup,
1189
+ ),
1190
+ const SizedBox(width: 14),
1191
+ Expanded(
1192
+ child: _GlassSurface(
1193
+ borderRadius: BorderRadius.circular(32),
1194
+ blurSigma: 28,
1195
+ boxShadow: _softPanelShadow,
1196
+ overlayGradient: _panelGradient,
1197
+ fillColor: _glassFill,
1198
+ child: ClipRRect(
1199
+ borderRadius: BorderRadius.circular(32),
1200
+ child: AnimatedSwitcher(
1201
+ duration: const Duration(milliseconds: 260),
1202
+ switchInCurve: Curves.easeOutCubic,
1203
+ switchOutCurve: Curves.easeInCubic,
1204
+ transitionBuilder: (child, animation) {
1205
+ final offset = Tween<Offset>(
1206
+ begin: const Offset(0.015, 0.02),
1207
+ end: Offset.zero,
1208
+ ).animate(animation);
1209
+ return FadeTransition(
1210
+ opacity: animation,
1211
+ child: SlideTransition(
1212
+ position: offset,
1213
+ child: child,
1214
+ ),
1215
+ );
1216
+ },
1217
+ child: KeyedSubtree(
1218
+ key: ValueKey<AppSection>(
1219
+ controller.selectedSection,
1220
+ ),
1221
+ child: _SectionBody(controller: controller),
1222
+ ),
1223
+ ),
1224
+ ),
1225
+ ),
1226
+ ),
1227
+ ],
1228
+ ),
1229
+ ),
1230
+ ),
1231
+ ),
1232
+ );
1233
+ }
1234
+
1235
+ return _AmbientBackdrop(
1236
+ child: Scaffold(
1237
+ backgroundColor: Colors.transparent,
1238
+ drawer: _MobileDrawer(
1239
+ controller: controller,
1240
+ expandedGroup: _expandedSidebarGroup,
1241
+ onToggleGroup: _toggleSidebarGroup,
1242
+ ),
1243
+ appBar: AppBar(
1244
+ title: Text(controller.selectedSection.navigationTitle),
1245
+ elevation: 0,
1246
+ ),
1247
+ body: SafeArea(
1248
+ child: Padding(
1249
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
1250
+ child: _GlassSurface(
1251
+ borderRadius: BorderRadius.circular(26),
1252
+ blurSigma: 24,
1253
+ boxShadow: _softPanelShadow,
1254
+ overlayGradient: _panelGradient,
1255
+ fillColor: _glassFill,
1256
+ child: ClipRRect(
1257
+ borderRadius: BorderRadius.circular(26),
1258
+ child: AnimatedSwitcher(
1259
+ duration: const Duration(milliseconds: 240),
1260
+ switchInCurve: Curves.easeOutCubic,
1261
+ switchOutCurve: Curves.easeInCubic,
1262
+ child: KeyedSubtree(
1263
+ key: ValueKey<AppSection>(controller.selectedSection),
1264
+ child: _SectionBody(controller: controller),
1265
+ ),
1266
+ ),
1267
+ ),
1268
+ ),
1269
+ ),
1270
+ ),
1271
+ ),
1272
+ );
1273
+ }
1274
+
1275
+ Future<void> _showBlockedSenderDialog(BlockedSenderNotice notice) async {
1276
+ _blockedDialogOpen = true;
1277
+ try {
1278
+ await showDialog<void>(
1279
+ context: context,
1280
+ barrierDismissible: true,
1281
+ builder: (dialogContext) {
1282
+ return AlertDialog(
1283
+ backgroundColor: _bgCard,
1284
+ title: Text('Allow sender on ${notice.platform.toUpperCase()}?'),
1285
+ content: SizedBox(
1286
+ width: 520,
1287
+ child: SingleChildScrollView(
1288
+ child: Column(
1289
+ mainAxisSize: MainAxisSize.min,
1290
+ crossAxisAlignment: CrossAxisAlignment.start,
1291
+ children: <Widget>[
1292
+ Text(
1293
+ notice.senderLabel,
1294
+ style: TextStyle(
1295
+ fontSize: 16,
1296
+ fontWeight: FontWeight.w700,
1297
+ ),
1298
+ ),
1299
+ if (notice.meta.isNotEmpty) ...<Widget>[
1300
+ const SizedBox(height: 6),
1301
+ Text(
1302
+ notice.meta,
1303
+ style: TextStyle(color: _textSecondary),
1304
+ ),
1305
+ ],
1306
+ const SizedBox(height: 12),
1307
+ Text(
1308
+ 'This sender is currently blocked by the access list. You can allow them now or jump to Messaging to edit the full list.',
1309
+ style: TextStyle(color: _textSecondary, height: 1.45),
1310
+ ),
1311
+ if (notice.suggestions.isNotEmpty) ...<Widget>[
1312
+ const SizedBox(height: 18),
1313
+ ...notice.suggestions.map(
1314
+ (suggestion) => Padding(
1315
+ padding: const EdgeInsets.only(bottom: 10),
1316
+ child: SizedBox(
1317
+ width: double.infinity,
1318
+ child: FilledButton.icon(
1319
+ onPressed: () async {
1320
+ Navigator.of(dialogContext).pop();
1321
+ await widget.controller
1322
+ .allowMessagingSuggestion(
1323
+ notice.platform,
1324
+ suggestion,
1325
+ );
1326
+ },
1327
+ icon: Icon(Icons.verified_user_outlined),
1328
+ label: Text(suggestion.label),
1329
+ ),
1330
+ ),
1331
+ ),
1332
+ ),
1333
+ ],
1334
+ ],
1335
+ ),
1336
+ ),
1337
+ ),
1338
+ actions: <Widget>[
1339
+ TextButton(
1340
+ onPressed: () {
1341
+ widget.controller.setSelectedSection(AppSection.messaging);
1342
+ Navigator.of(dialogContext).pop();
1343
+ },
1344
+ child: Text('Open Messaging'),
1345
+ ),
1346
+ TextButton(
1347
+ onPressed: () => Navigator.of(dialogContext).pop(),
1348
+ child: Text('Dismiss'),
1349
+ ),
1350
+ ],
1351
+ );
1352
+ },
1353
+ );
1354
+ } finally {
1355
+ widget.controller.consumeBlockedSenderNotice(notice.id);
1356
+ if (mounted) {
1357
+ setState(() => _blockedDialogOpen = false);
1358
+ } else {
1359
+ _blockedDialogOpen = false;
1360
+ }
1361
+ }
1362
+ }
1363
+ }
1364
+
1365
+ class _Sidebar extends StatelessWidget {
1366
+ const _Sidebar({
1367
+ required this.controller,
1368
+ required this.expandedGroup,
1369
+ required this.onToggleGroup,
1370
+ });
1371
+
1372
+ final NeoAgentController controller;
1373
+ final SidebarGroup? expandedGroup;
1374
+ final ValueChanged<SidebarGroup> onToggleGroup;
1375
+
1376
+ @override
1377
+ Widget build(BuildContext context) {
1378
+ return _GlassSurface(
1379
+ width: 254,
1380
+ borderRadius: BorderRadius.circular(30),
1381
+ blurSigma: 26,
1382
+ boxShadow: _softPanelShadow,
1383
+ fillColor: _bgSecondary.withValues(alpha: 0.34),
1384
+ overlayGradient: LinearGradient(
1385
+ colors: <Color>[
1386
+ _bgSecondary.withValues(alpha: 0.96),
1387
+ _bgTertiary.withValues(alpha: 0.88),
1388
+ ],
1389
+ begin: Alignment.topCenter,
1390
+ end: Alignment.bottomCenter,
1391
+ ),
1392
+ child: Column(
1393
+ children: <Widget>[
1394
+ Container(
1395
+ padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
1396
+ decoration: BoxDecoration(
1397
+ border: Border(bottom: BorderSide(color: _border)),
1398
+ ),
1399
+ child: Column(
1400
+ crossAxisAlignment: CrossAxisAlignment.start,
1401
+ children: <Widget>[
1402
+ Row(
1403
+ children: <Widget>[
1404
+ Expanded(
1405
+ child: const _BrandLockup(
1406
+ logoSize: 34,
1407
+ titleFontSize: 18,
1408
+ direction: Axis.horizontal,
1409
+ spacing: 12,
1410
+ alignment: CrossAxisAlignment.start,
1411
+ ),
1412
+ ),
1413
+ ],
1414
+ ),
1415
+ ],
1416
+ ),
1417
+ ),
1418
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
1419
+ Padding(
1420
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
1421
+ child: _AgentSwitcher(controller: controller),
1422
+ ),
1423
+ ],
1424
+ Expanded(
1425
+ child: ListView(
1426
+ padding: const EdgeInsets.all(8),
1427
+ children: _buildSidebarItems(
1428
+ controller,
1429
+ onSelect: controller.setSelectedSection,
1430
+ expandedGroup: expandedGroup,
1431
+ onToggleGroup: onToggleGroup,
1432
+ ),
1433
+ ),
1434
+ ),
1435
+ Container(
1436
+ padding: const EdgeInsets.all(10),
1437
+ decoration: BoxDecoration(
1438
+ border: Border(top: BorderSide(color: _border)),
1439
+ ),
1440
+ child: Column(
1441
+ children: <Widget>[
1442
+ Row(
1443
+ children: <Widget>[
1444
+ Expanded(
1445
+ child: Text(
1446
+ controller.accountLabel,
1447
+ maxLines: 1,
1448
+ overflow: TextOverflow.ellipsis,
1449
+ style: TextStyle(
1450
+ color: _textSecondary,
1451
+ fontSize: 12,
1452
+ fontWeight: FontWeight.w600,
1453
+ ),
1454
+ ),
1455
+ ),
1456
+ const SizedBox(width: 8),
1457
+ _ProfileSettingsButton(
1458
+ controller: controller,
1459
+ onTap: () => controller.setSelectedSection(
1460
+ AppSection.accountSettings,
1461
+ ),
1462
+ ),
1463
+ const SizedBox(width: 8),
1464
+ _SidebarIconButton(
1465
+ tooltip: 'Logout',
1466
+ icon: Icons.logout,
1467
+ onTap: controller.logout,
1468
+ ),
1469
+ ],
1470
+ ),
1471
+ ],
1472
+ ),
1473
+ ),
1474
+ ],
1475
+ ),
1476
+ );
1477
+ }
1478
+ }
1479
+
1480
+ class _AgentSwitcher extends StatefulWidget {
1481
+ const _AgentSwitcher({required this.controller, this.onChanged});
1482
+
1483
+ final NeoAgentController controller;
1484
+ final VoidCallback? onChanged;
1485
+
1486
+ @override
1487
+ State<_AgentSwitcher> createState() => _AgentSwitcherState();
1488
+ }
1489
+
1490
+ class _AgentSwitcherState extends State<_AgentSwitcher> {
1491
+ final MenuController _menuController = MenuController();
1492
+
1493
+ void _toggleMenu() {
1494
+ if (_menuController.isOpen) {
1495
+ _menuController.close();
1496
+ } else {
1497
+ _menuController.open();
1498
+ }
1499
+ setState(() {});
1500
+ }
1501
+
1502
+ Future<void> _selectAgent(String agentId) async {
1503
+ if (widget.controller.selectedAgentId == agentId) {
1504
+ _menuController.close();
1505
+ setState(() {});
1506
+ return;
1507
+ }
1508
+ widget.onChanged?.call();
1509
+ _menuController.close();
1510
+ setState(() {});
1511
+ await widget.controller.switchAgent(agentId);
1512
+ }
1513
+
1514
+ @override
1515
+ Widget build(BuildContext context) {
1516
+ final controller = widget.controller;
1517
+ final selectedAgent =
1518
+ controller.activeAgent ?? controller.agentProfiles.first;
1519
+ final isMenuOpen = _menuController.isOpen;
1520
+
1521
+ return MenuAnchor(
1522
+ controller: _menuController,
1523
+ style: MenuStyle(
1524
+ backgroundColor: WidgetStateProperty.all(Colors.transparent),
1525
+ surfaceTintColor: WidgetStateProperty.all(Colors.transparent),
1526
+ shadowColor: WidgetStateProperty.all(Colors.transparent),
1527
+ elevation: WidgetStateProperty.all(0),
1528
+ padding: WidgetStateProperty.all(EdgeInsets.zero),
1529
+ shape: WidgetStateProperty.all(
1530
+ RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
1531
+ ),
1532
+ ),
1533
+ crossAxisUnconstrained: false,
1534
+ onOpen: () => setState(() {}),
1535
+ onClose: () => setState(() {}),
1536
+ menuChildren: <Widget>[
1537
+ SizedBox(
1538
+ width: 320,
1539
+ child: _GlassSurface(
1540
+ borderRadius: BorderRadius.circular(24),
1541
+ blurSigma: 28,
1542
+ fillColor: _bgCard.withValues(alpha: 0.9),
1543
+ overlayGradient: LinearGradient(
1544
+ colors: <Color>[
1545
+ Colors.white.withValues(alpha: 0.1),
1546
+ _bgSecondary.withValues(alpha: 0.92),
1547
+ _bgPrimary.withValues(alpha: 0.94),
1548
+ ],
1549
+ begin: Alignment.topLeft,
1550
+ end: Alignment.bottomRight,
1551
+ ),
1552
+ boxShadow: <BoxShadow>[
1553
+ ..._softPanelShadow,
1554
+ BoxShadow(
1555
+ color: Colors.black.withValues(alpha: 0.22),
1556
+ blurRadius: 28,
1557
+ offset: const Offset(0, 16),
1558
+ ),
1559
+ ],
1560
+ child: Padding(
1561
+ padding: const EdgeInsets.all(10),
1562
+ child: Column(
1563
+ mainAxisSize: MainAxisSize.min,
1564
+ children: controller.agentProfiles
1565
+ .map(
1566
+ (agent) => Padding(
1567
+ padding: const EdgeInsets.symmetric(vertical: 3),
1568
+ child: _AgentSwitcherMenuItem(
1569
+ agent: agent,
1570
+ selected: agent.id == controller.selectedAgentId,
1571
+ onTap: () => _selectAgent(agent.id),
1572
+ ),
1573
+ ),
1574
+ )
1575
+ .toList(),
1576
+ ),
1577
+ ),
1578
+ ),
1579
+ ),
1580
+ ],
1581
+ builder: (context, menuController, child) {
1582
+ return Material(
1583
+ color: Colors.transparent,
1584
+ child: InkWell(
1585
+ borderRadius: BorderRadius.circular(24),
1586
+ onTap: _toggleMenu,
1587
+ child: AnimatedContainer(
1588
+ duration: const Duration(milliseconds: 180),
1589
+ curve: Curves.easeOutCubic,
1590
+ padding: const EdgeInsets.fromLTRB(12, 9, 12, 9),
1591
+ decoration: BoxDecoration(
1592
+ borderRadius: BorderRadius.circular(24),
1593
+ gradient: LinearGradient(
1594
+ colors: <Color>[
1595
+ Colors.white.withValues(alpha: isMenuOpen ? 0.13 : 0.08),
1596
+ _accentMuted.withValues(alpha: isMenuOpen ? 0.24 : 0.14),
1597
+ _bgSecondary.withValues(alpha: isMenuOpen ? 0.92 : 0.84),
1598
+ ],
1599
+ begin: Alignment.topLeft,
1600
+ end: Alignment.bottomRight,
1601
+ ),
1602
+ border: Border.all(
1603
+ color: isMenuOpen
1604
+ ? _accent.withValues(alpha: 0.65)
1605
+ : _borderLight,
1606
+ ),
1607
+ boxShadow: isMenuOpen
1608
+ ? <BoxShadow>[
1609
+ BoxShadow(
1610
+ color: _accent.withValues(alpha: 0.16),
1611
+ blurRadius: 24,
1612
+ offset: const Offset(0, 10),
1613
+ ),
1614
+ ]
1615
+ : null,
1616
+ ),
1617
+ child: Row(
1618
+ children: <Widget>[
1619
+ _AgentGlyph(
1620
+ agent: selectedAgent,
1621
+ selected: true,
1622
+ compact: true,
1623
+ ),
1624
+ const SizedBox(width: 10),
1625
+ Expanded(
1626
+ child: Column(
1627
+ crossAxisAlignment: CrossAxisAlignment.start,
1628
+ mainAxisSize: MainAxisSize.min,
1629
+ children: <Widget>[
1630
+ Row(
1631
+ children: <Widget>[
1632
+ Flexible(
1633
+ child: Text(
1634
+ selectedAgent.displayName,
1635
+ maxLines: 1,
1636
+ overflow: TextOverflow.ellipsis,
1637
+ style: const TextStyle(
1638
+ fontSize: 13.5,
1639
+ fontWeight: FontWeight.w700,
1640
+ letterSpacing: -0.15,
1641
+ ),
1642
+ ),
1643
+ ),
1644
+ if (selectedAgent.isDefault) ...<Widget>[
1645
+ const SizedBox(width: 8),
1646
+ _AgentTag(
1647
+ label: 'DEFAULT',
1648
+ color: _accent,
1649
+ foreground: _accentHover,
1650
+ ),
1651
+ ],
1652
+ ],
1653
+ ),
1654
+ const SizedBox(height: 2),
1655
+ Text(
1656
+ _agentSwitcherSubtitle(selectedAgent),
1657
+ maxLines: 1,
1658
+ overflow: TextOverflow.ellipsis,
1659
+ style: TextStyle(
1660
+ color: _textSecondary,
1661
+ fontSize: 10.8,
1662
+ fontWeight: FontWeight.w500,
1663
+ height: 1.2,
1664
+ ),
1665
+ ),
1666
+ ],
1667
+ ),
1668
+ ),
1669
+ const SizedBox(width: 8),
1670
+ AnimatedRotation(
1671
+ turns: isMenuOpen ? 0.5 : 0,
1672
+ duration: const Duration(milliseconds: 180),
1673
+ curve: Curves.easeOutCubic,
1674
+ child: Icon(
1675
+ Icons.keyboard_arrow_down_rounded,
1676
+ size: 20,
1677
+ color: isMenuOpen ? _accentHover : _textSecondary,
1678
+ ),
1679
+ ),
1680
+ ],
1681
+ ),
1682
+ ),
1683
+ ),
1684
+ );
1685
+ },
1686
+ );
1687
+ }
1688
+ }
1689
+
1690
+ String _agentSwitcherSubtitle(AgentProfile agent) {
1691
+ if (agent.description.trim().isNotEmpty) {
1692
+ return agent.description.trim();
1693
+ }
1694
+ if (agent.responsibilities.trim().isNotEmpty) {
1695
+ return agent.responsibilities.trim();
1696
+ }
1697
+ return agent.canDelegate
1698
+ ? 'Can coordinate delegated work'
1699
+ : 'Focused execution profile';
1700
+ }
1701
+
1702
+ class _AgentSwitcherMenuItem extends StatelessWidget {
1703
+ const _AgentSwitcherMenuItem({
1704
+ required this.agent,
1705
+ required this.selected,
1706
+ required this.onTap,
1707
+ });
1708
+
1709
+ final AgentProfile agent;
1710
+ final bool selected;
1711
+ final VoidCallback onTap;
1712
+
1713
+ @override
1714
+ Widget build(BuildContext context) {
1715
+ return Material(
1716
+ color: Colors.transparent,
1717
+ child: InkWell(
1718
+ borderRadius: BorderRadius.circular(18),
1719
+ onTap: onTap,
1720
+ child: Ink(
1721
+ decoration: BoxDecoration(
1722
+ borderRadius: BorderRadius.circular(18),
1723
+ gradient: selected
1724
+ ? LinearGradient(
1725
+ colors: <Color>[
1726
+ _accent.withValues(alpha: 0.18),
1727
+ _accentMuted.withValues(alpha: 0.3),
1728
+ ],
1729
+ begin: Alignment.topLeft,
1730
+ end: Alignment.bottomRight,
1731
+ )
1732
+ : null,
1733
+ color: selected ? null : Colors.white.withValues(alpha: 0.025),
1734
+ border: Border.all(
1735
+ color: selected ? _accent.withValues(alpha: 0.5) : _borderLight,
1736
+ ),
1737
+ ),
1738
+ child: Padding(
1739
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
1740
+ child: Row(
1741
+ crossAxisAlignment: CrossAxisAlignment.start,
1742
+ children: <Widget>[
1743
+ _AgentGlyph(agent: agent, selected: selected, compact: true),
1744
+ const SizedBox(width: 12),
1745
+ Expanded(
1746
+ child: Column(
1747
+ crossAxisAlignment: CrossAxisAlignment.start,
1748
+ children: <Widget>[
1749
+ Row(
1750
+ children: <Widget>[
1751
+ Expanded(
1752
+ child: Text(
1753
+ agent.displayName,
1754
+ maxLines: 1,
1755
+ overflow: TextOverflow.ellipsis,
1756
+ style: TextStyle(
1757
+ color: _textPrimary,
1758
+ fontSize: 13.5,
1759
+ fontWeight: selected
1760
+ ? FontWeight.w700
1761
+ : FontWeight.w600,
1762
+ letterSpacing: -0.1,
1763
+ ),
1764
+ ),
1765
+ ),
1766
+ if (agent.isDefault) ...<Widget>[
1767
+ const SizedBox(width: 8),
1768
+ _AgentTag(
1769
+ label: 'DEFAULT',
1770
+ color: _accent,
1771
+ foreground: _accentHover,
1772
+ ),
1773
+ ],
1774
+ ],
1775
+ ),
1776
+ const SizedBox(height: 4),
1777
+ Text(
1778
+ _agentSwitcherSubtitle(agent),
1779
+ maxLines: 2,
1780
+ overflow: TextOverflow.ellipsis,
1781
+ style: TextStyle(
1782
+ color: _textSecondary,
1783
+ fontSize: 11.5,
1784
+ height: 1.3,
1785
+ fontWeight: FontWeight.w500,
1786
+ ),
1787
+ ),
1788
+ ],
1789
+ ),
1790
+ ),
1791
+ const SizedBox(width: 10),
1792
+ AnimatedOpacity(
1793
+ duration: const Duration(milliseconds: 140),
1794
+ opacity: selected ? 1 : 0,
1795
+ child: Container(
1796
+ width: 24,
1797
+ height: 24,
1798
+ decoration: BoxDecoration(
1799
+ shape: BoxShape.circle,
1800
+ color: _accent.withValues(alpha: 0.2),
1801
+ border: Border.all(
1802
+ color: _accent.withValues(alpha: 0.45),
1803
+ ),
1804
+ ),
1805
+ child: Icon(
1806
+ Icons.check_rounded,
1807
+ size: 15,
1808
+ color: _accentHover,
1809
+ ),
1810
+ ),
1811
+ ),
1812
+ ],
1813
+ ),
1814
+ ),
1815
+ ),
1816
+ ),
1817
+ );
1818
+ }
1819
+ }
1820
+
1821
+ class _AgentGlyph extends StatelessWidget {
1822
+ const _AgentGlyph({
1823
+ required this.agent,
1824
+ required this.selected,
1825
+ required this.compact,
1826
+ });
1827
+
1828
+ final AgentProfile agent;
1829
+ final bool selected;
1830
+ final bool compact;
1831
+
1832
+ @override
1833
+ Widget build(BuildContext context) {
1834
+ final baseColor = agent.isDefault ? _accent : _accentAlt;
1835
+ final initials = _agentInitials(agent.displayName);
1836
+ final size = compact ? 42.0 : 44.0;
1837
+ return Container(
1838
+ width: size,
1839
+ height: size,
1840
+ decoration: BoxDecoration(
1841
+ shape: BoxShape.circle,
1842
+ gradient: LinearGradient(
1843
+ colors: <Color>[
1844
+ baseColor.withValues(alpha: selected ? 0.85 : 0.65),
1845
+ Color.lerp(
1846
+ baseColor,
1847
+ _bgSecondary,
1848
+ 0.35,
1849
+ )!.withValues(alpha: selected ? 0.9 : 0.78),
1850
+ ],
1851
+ begin: Alignment.topLeft,
1852
+ end: Alignment.bottomRight,
1853
+ ),
1854
+ border: Border.all(
1855
+ color: Colors.white.withValues(alpha: selected ? 0.34 : 0.2),
1856
+ ),
1857
+ boxShadow: <BoxShadow>[
1858
+ BoxShadow(
1859
+ color: baseColor.withValues(alpha: selected ? 0.22 : 0.12),
1860
+ blurRadius: compact ? 12 : 16,
1861
+ offset: const Offset(0, 6),
1862
+ ),
1863
+ ],
1864
+ ),
1865
+ child: Stack(
1866
+ alignment: Alignment.center,
1867
+ children: <Widget>[
1868
+ Icon(
1869
+ agent.canDelegate ? Icons.hub_rounded : Icons.smart_toy_outlined,
1870
+ size: compact ? 17 : 18,
1871
+ color: Colors.white.withValues(alpha: 0.2),
1872
+ ),
1873
+ Text(
1874
+ initials,
1875
+ style: TextStyle(
1876
+ color: Colors.white,
1877
+ fontSize: compact ? 12 : 12.5,
1878
+ fontWeight: FontWeight.w800,
1879
+ letterSpacing: 0.35,
1880
+ ),
1881
+ ),
1882
+ ],
1883
+ ),
1884
+ );
1885
+ }
1886
+ }
1887
+
1888
+ class _AgentTag extends StatelessWidget {
1889
+ const _AgentTag({
1890
+ required this.label,
1891
+ required this.color,
1892
+ required this.foreground,
1893
+ });
1894
+
1895
+ final String label;
1896
+ final Color color;
1897
+ final Color foreground;
1898
+
1899
+ @override
1900
+ Widget build(BuildContext context) {
1901
+ return Container(
1902
+ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
1903
+ decoration: BoxDecoration(
1904
+ borderRadius: BorderRadius.circular(999),
1905
+ color: color.withValues(alpha: 0.14),
1906
+ border: Border.all(color: color.withValues(alpha: 0.3)),
1907
+ ),
1908
+ child: Text(
1909
+ label,
1910
+ style: TextStyle(
1911
+ color: foreground,
1912
+ fontSize: 9.5,
1913
+ fontWeight: FontWeight.w800,
1914
+ letterSpacing: 0.6,
1915
+ ),
1916
+ ),
1917
+ );
1918
+ }
1919
+ }
1920
+
1921
+ String _agentInitials(String label) {
1922
+ final parts = label
1923
+ .trim()
1924
+ .split(RegExp(r'\s+'))
1925
+ .where((part) => part.isNotEmpty)
1926
+ .toList(growable: false);
1927
+ if (parts.isEmpty) return 'A';
1928
+ if (parts.length == 1) {
1929
+ return parts.first.characters.take(2).toString().toUpperCase();
1930
+ }
1931
+ return (parts.first.characters.first + parts.last.characters.first)
1932
+ .toUpperCase();
1933
+ }
1934
+
1935
+ class _ProfileSettingsButton extends StatelessWidget {
1936
+ const _ProfileSettingsButton({required this.controller, required this.onTap});
1937
+
1938
+ final NeoAgentController controller;
1939
+ final VoidCallback onTap;
1940
+
1941
+ @override
1942
+ Widget build(BuildContext context) {
1943
+ final label = controller.accountLabel.trim();
1944
+ final initial = label.isEmpty ? 'N' : label.characters.first.toUpperCase();
1945
+ final active = controller.selectedSection == AppSection.accountSettings;
1946
+ return Tooltip(
1947
+ message: 'Account settings',
1948
+ child: InkWell(
1949
+ borderRadius: BorderRadius.circular(18),
1950
+ onTap: onTap,
1951
+ child: Stack(
1952
+ clipBehavior: Clip.none,
1953
+ children: <Widget>[
1954
+ Container(
1955
+ width: 34,
1956
+ height: 34,
1957
+ decoration: BoxDecoration(
1958
+ color: active ? _accentMuted : _bgCard,
1959
+ shape: BoxShape.circle,
1960
+ border: Border.all(color: active ? _accent : _borderLight),
1961
+ ),
1962
+ alignment: Alignment.center,
1963
+ child: Text(
1964
+ initial,
1965
+ style: TextStyle(
1966
+ color: active ? _accentHover : _textPrimary,
1967
+ fontWeight: FontWeight.w800,
1968
+ fontSize: 13,
1969
+ ),
1970
+ ),
1971
+ ),
1972
+ Positioned(
1973
+ right: -2,
1974
+ bottom: -2,
1975
+ child: Container(
1976
+ width: 17,
1977
+ height: 17,
1978
+ decoration: BoxDecoration(
1979
+ color: _bgSecondary,
1980
+ shape: BoxShape.circle,
1981
+ border: Border.all(color: active ? _accent : _borderLight),
1982
+ ),
1983
+ child: Icon(
1984
+ Icons.settings,
1985
+ size: 11,
1986
+ color: active ? _accentHover : _textSecondary,
1987
+ ),
1988
+ ),
1989
+ ),
1990
+ ],
1991
+ ),
1992
+ ),
1993
+ );
1994
+ }
1995
+ }
1996
+
1997
+ class _MobileDrawer extends StatelessWidget {
1998
+ const _MobileDrawer({
1999
+ required this.controller,
2000
+ required this.expandedGroup,
2001
+ required this.onToggleGroup,
2002
+ });
2003
+
2004
+ final NeoAgentController controller;
2005
+ final SidebarGroup? expandedGroup;
2006
+ final ValueChanged<SidebarGroup> onToggleGroup;
2007
+
2008
+ @override
2009
+ Widget build(BuildContext context) {
2010
+ return Drawer(
2011
+ backgroundColor: _bgSecondary,
2012
+ child: SafeArea(
2013
+ child: Column(
2014
+ children: <Widget>[
2015
+ Padding(
2016
+ padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
2017
+ child: Column(
2018
+ crossAxisAlignment: CrossAxisAlignment.start,
2019
+ children: <Widget>[
2020
+ Row(
2021
+ children: <Widget>[
2022
+ Expanded(
2023
+ child: const _BrandLockup(
2024
+ logoSize: 30,
2025
+ titleFontSize: 18,
2026
+ direction: Axis.horizontal,
2027
+ spacing: 10,
2028
+ alignment: CrossAxisAlignment.start,
2029
+ ),
2030
+ ),
2031
+ ],
2032
+ ),
2033
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
2034
+ const SizedBox(height: 12),
2035
+ _AgentSwitcher(
2036
+ controller: controller,
2037
+ onChanged: () => Navigator.of(context).pop(),
2038
+ ),
2039
+ ],
2040
+ ],
2041
+ ),
2042
+ ),
2043
+ Expanded(
2044
+ child: ListView(
2045
+ padding: const EdgeInsets.symmetric(horizontal: 8),
2046
+ children: _buildSidebarItems(
2047
+ controller,
2048
+ onSelect: (section) {
2049
+ controller.setSelectedSection(section);
2050
+ Navigator.of(context).pop();
2051
+ },
2052
+ expandedGroup: expandedGroup,
2053
+ onToggleGroup: onToggleGroup,
2054
+ ),
2055
+ ),
2056
+ ),
2057
+ Padding(
2058
+ padding: const EdgeInsets.all(8),
2059
+ child: Row(
2060
+ children: <Widget>[
2061
+ const Spacer(),
2062
+ _ProfileSettingsButton(
2063
+ controller: controller,
2064
+ onTap: () {
2065
+ Navigator.of(context).pop();
2066
+ controller.setSelectedSection(AppSection.accountSettings);
2067
+ },
2068
+ ),
2069
+ const SizedBox(width: 8),
2070
+ _SidebarIconButton(
2071
+ tooltip: 'Logout',
2072
+ icon: Icons.logout,
2073
+ onTap: () {
2074
+ Navigator.of(context).pop();
2075
+ controller.logout();
2076
+ },
2077
+ ),
2078
+ ],
2079
+ ),
2080
+ ),
2081
+ ],
2082
+ ),
2083
+ ),
2084
+ );
2085
+ }
2086
+ }
2087
+
2088
+ class _SectionBody extends StatelessWidget {
2089
+ const _SectionBody({required this.controller});
2090
+
2091
+ final NeoAgentController controller;
2092
+
2093
+ @override
2094
+ Widget build(BuildContext context) {
2095
+ switch (controller.selectedSection) {
2096
+ case AppSection.chat:
2097
+ return ChatPanel(controller: controller);
2098
+ case AppSection.voiceAssistant:
2099
+ return VoiceAssistantPanel(controller: controller);
2100
+ case AppSection.devices:
2101
+ return DevicesPanel(controller: controller);
2102
+ case AppSection.recordings:
2103
+ return RecordingsPanel(controller: controller);
2104
+ case AppSection.messaging:
2105
+ return MessagingPanel(controller: controller);
2106
+ case AppSection.runs:
2107
+ return RunsPanel(controller: controller);
2108
+ case AppSection.settings:
2109
+ return SettingsPanel(controller: controller);
2110
+ case AppSection.accountSettings:
2111
+ return AccountSettingsPanel(controller: controller);
2112
+ case AppSection.logs:
2113
+ return LogsPanel(controller: controller);
2114
+ case AppSection.skills:
2115
+ return SkillsPanel(controller: controller);
2116
+ case AppSection.agents:
2117
+ return AgentsPanel(controller: controller);
2118
+ case AppSection.integrations:
2119
+ return IntegrationsPanel(controller: controller);
2120
+ case AppSection.memory:
2121
+ return MemoryPanel(controller: controller);
2122
+ case AppSection.tasks:
2123
+ return TasksPanel(controller: controller);
2124
+ case AppSection.widgets:
2125
+ return WidgetsPanel(controller: controller);
2126
+ case AppSection.mcp:
2127
+ return McpPanel(controller: controller);
2128
+ case AppSection.health:
2129
+ return controller.showHealthSection
2130
+ ? HealthPanel(controller: controller)
2131
+ : ChatPanel(controller: controller);
2132
+ }
2133
+ }
2134
+ }