neoagent 2.3.1-beta.4 → 2.3.1-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/.env.example +45 -0
  2. package/docs/capabilities.md +2 -2
  3. package/docs/configuration.md +12 -5
  4. package/docs/hardware.md +1 -1
  5. package/docs/integrations.md +2 -3
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +109 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  30. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  31. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  32. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  33. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  35. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  36. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  37. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  38. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  39. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  43. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  44. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  45. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  46. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  48. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  49. package/flutter_app/android/build.gradle.kts +24 -0
  50. package/flutter_app/android/ci-release.keystore +0 -0
  51. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  52. package/flutter_app/android/gradle.properties +3 -0
  53. package/flutter_app/android/key.properties +4 -0
  54. package/flutter_app/android/settings.gradle.kts +26 -0
  55. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  57. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  58. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  59. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  60. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  62. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  63. package/flutter_app/lib/features/location/location_service.dart +119 -0
  64. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  65. package/flutter_app/lib/main.dart +99 -0
  66. package/flutter_app/lib/main_account_settings.dart +1250 -0
  67. package/flutter_app/lib/main_admin.dart +886 -0
  68. package/flutter_app/lib/main_app_shell.dart +1682 -0
  69. package/flutter_app/lib/main_chat.dart +3352 -0
  70. package/flutter_app/lib/main_controller.dart +6781 -0
  71. package/flutter_app/lib/main_devices.dart +2301 -0
  72. package/flutter_app/lib/main_integrations.dart +1129 -0
  73. package/flutter_app/lib/main_launcher.dart +959 -0
  74. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  75. package/flutter_app/lib/main_models.dart +3546 -0
  76. package/flutter_app/lib/main_navigation.dart +193 -0
  77. package/flutter_app/lib/main_operations.dart +4851 -0
  78. package/flutter_app/lib/main_recordings.dart +870 -0
  79. package/flutter_app/lib/main_runtime.dart +806 -0
  80. package/flutter_app/lib/main_settings.dart +2024 -0
  81. package/flutter_app/lib/main_shared.dart +2861 -0
  82. package/flutter_app/lib/main_theme.dart +204 -0
  83. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  84. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  85. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  86. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  87. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  88. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  89. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  90. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  91. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  92. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  93. package/flutter_app/lib/src/backend_client.dart +1833 -0
  94. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  95. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  96. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  97. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  98. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  99. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  100. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  101. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  102. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  103. package/flutter_app/lib/src/health_bridge.dart +136 -0
  104. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  105. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  106. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  107. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  108. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  109. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  110. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  111. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  112. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  113. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  114. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  115. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  116. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  117. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  118. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  119. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  120. package/flutter_app/lib/src/theme/palette.dart +81 -0
  121. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  122. package/flutter_app/linux/CMakeLists.txt +128 -0
  123. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  124. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  125. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  126. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  127. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  128. package/flutter_app/linux/runner/main.cc +6 -0
  129. package/flutter_app/linux/runner/my_application.cc +144 -0
  130. package/flutter_app/linux/runner/my_application.h +18 -0
  131. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  132. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  133. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  134. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  135. package/flutter_app/macos/Podfile +42 -0
  136. package/flutter_app/macos/Podfile.lock +87 -0
  137. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  138. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  139. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  140. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  141. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  142. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  143. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  144. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  145. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  146. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  147. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  148. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  149. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  150. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  151. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  152. package/flutter_app/macos/Runner/Info.plist +36 -0
  153. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  154. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  155. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  156. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  157. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  158. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  159. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  160. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  161. package/flutter_app/patch_strings.py +12 -0
  162. package/flutter_app/pubspec.lock +1088 -0
  163. package/flutter_app/pubspec.yaml +53 -0
  164. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  165. package/flutter_app/test/recording_payloads_test.dart +53 -0
  166. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  167. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  168. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  169. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  170. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  171. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  172. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  173. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  174. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  175. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  176. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  177. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  178. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  179. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  180. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  181. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  182. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  183. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  184. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  185. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  186. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  187. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  188. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  189. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  190. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  191. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  192. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  193. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  194. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  195. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  196. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  197. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  198. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  199. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  200. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  201. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  202. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  203. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  204. package/flutter_app/web/favicon.png +0 -0
  205. package/flutter_app/web/favicon.svg +12 -0
  206. package/flutter_app/web/icons/Icon-192.png +0 -0
  207. package/flutter_app/web/icons/Icon-512.png +0 -0
  208. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  209. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  210. package/flutter_app/web/index.html +39 -0
  211. package/flutter_app/web/manifest.json +35 -0
  212. package/flutter_app/windows/CMakeLists.txt +108 -0
  213. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  214. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  215. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  216. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  217. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  218. package/flutter_app/windows/runner/Runner.rc +121 -0
  219. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  220. package/flutter_app/windows/runner/flutter_window.h +37 -0
  221. package/flutter_app/windows/runner/main.cpp +53 -0
  222. package/flutter_app/windows/runner/resource.h +16 -0
  223. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  224. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  225. package/flutter_app/windows/runner/utils.cpp +65 -0
  226. package/flutter_app/windows/runner/utils.h +19 -0
  227. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  228. package/flutter_app/windows/runner/win32_window.h +102 -0
  229. package/lib/install_helpers.js +31 -0
  230. package/lib/manager.js +227 -6
  231. package/package.json +3 -1
  232. package/server/db/database.js +110 -0
  233. package/server/http/middleware.js +55 -2
  234. package/server/http/routes.js +1 -0
  235. package/server/index.js +3 -0
  236. package/server/public/.last_build_id +1 -1
  237. package/server/public/assets/NOTICES +1 -1
  238. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  239. package/server/public/canvaskit/wimp.wasm +0 -0
  240. package/server/public/flutter_bootstrap.js +2 -2
  241. package/server/public/main.dart.js +74324 -73132
  242. package/server/routes/integrations.js +108 -1
  243. package/server/routes/memory.js +11 -2
  244. package/server/{http/routes → routes}/screenHistory.js +2 -2
  245. package/server/routes/settings.js +75 -2
  246. package/server/{http/routes → routes}/triggers.js +2 -2
  247. package/server/routes/wearable.js +67 -0
  248. package/server/services/ai/models.js +30 -0
  249. package/server/services/ai/providers/githubCopilot.js +97 -0
  250. package/server/services/ai/providers/openai.js +2 -1
  251. package/server/services/ai/providers/openaiCodex.js +31 -0
  252. package/server/services/ai/settings.js +20 -0
  253. package/server/services/ai/toolSelector.js +14 -1
  254. package/server/services/ai/tools.js +77 -4
  255. package/server/services/desktop/screenRecorder.js +65 -9
  256. package/server/services/integrations/env.js +5 -0
  257. package/server/services/integrations/figma/provider.js +1 -0
  258. package/server/services/integrations/github/common.js +106 -0
  259. package/server/services/integrations/github/provider.js +499 -0
  260. package/server/services/integrations/github/repos.js +1124 -0
  261. package/server/services/integrations/google/provider.js +1 -0
  262. package/server/services/integrations/home_assistant/provider.js +325 -26
  263. package/server/services/integrations/manager.js +88 -12
  264. package/server/services/integrations/microsoft/provider.js +1 -0
  265. package/server/services/integrations/oauth_provider.js +25 -8
  266. package/server/services/integrations/provider_config_store.js +85 -0
  267. package/server/services/integrations/registry.js +4 -0
  268. package/server/services/integrations/spotify/provider.js +1 -0
  269. package/server/services/integrations/trello/provider.js +842 -0
  270. package/server/services/manager.js +46 -1
  271. package/server/services/mcp/client.js +120 -23
  272. package/server/services/memory/manager.js +39 -2
  273. package/server/services/messaging/access_policy.js +10 -0
  274. package/server/services/messaging/manager.js +49 -0
  275. package/server/services/messaging/meshtastic.js +260 -0
  276. package/server/services/messaging/meshtastic_env.js +100 -0
  277. package/server/services/messaging/meshtastic_protocol.js +476 -0
  278. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  279. package/server/services/tasks/runtime.js +1 -1
  280. package/server/services/voice/openaiClient.js +4 -1
  281. package/server/services/voice/openaiSpeech.js +6 -1
  282. package/server/services/voice/providers.js +52 -12
  283. package/server/services/voice/runtimeManager.js +136 -19
  284. package/server/services/voice/turnRunner.js +29 -9
  285. package/server/services/wearable/firmware_manifest.js +370 -0
  286. package/server/services/wearable/gateway.js +350 -0
  287. package/server/services/wearable/protocol.js +45 -0
  288. package/server/services/wearable/service.js +244 -0
  289. package/server/utils/local_secrets.js +56 -0
  290. package/server/utils/logger.js +37 -9
@@ -0,0 +1,886 @@
1
+ part of 'main.dart';
2
+
3
+ class AgentsPanel extends StatelessWidget {
4
+ const AgentsPanel({super.key, required this.controller});
5
+
6
+ final NeoAgentController controller;
7
+
8
+ @override
9
+ Widget build(BuildContext context) {
10
+ return ListView(
11
+ padding: _pagePadding(context),
12
+ children: <Widget>[
13
+ _PageTitle(
14
+ title: 'Agents',
15
+ subtitle:
16
+ 'Create specialist bots with separate memory, settings, tools, and account assignments.',
17
+ trailing: FilledButton.icon(
18
+ onPressed: () => openAgentEditor(context, controller),
19
+ icon: Icon(Icons.add),
20
+ label: Text('Add Agent'),
21
+ ),
22
+ ),
23
+ if (controller.errorMessage != null) ...<Widget>[
24
+ _InlineError(message: controller.errorMessage!),
25
+ const SizedBox(height: 16),
26
+ ],
27
+ if (controller.agentProfiles.isEmpty)
28
+ const _EmptyCard(
29
+ title: 'No agents yet',
30
+ subtitle: 'The main agent is created automatically when needed.',
31
+ )
32
+ else
33
+ ...controller.agentProfiles.map(
34
+ (agent) => Padding(
35
+ padding: const EdgeInsets.only(bottom: 14),
36
+ child: Card(
37
+ child: Padding(
38
+ padding: const EdgeInsets.all(18),
39
+ child: Column(
40
+ crossAxisAlignment: CrossAxisAlignment.start,
41
+ children: <Widget>[
42
+ Row(
43
+ children: <Widget>[
44
+ Expanded(
45
+ child: Text(
46
+ agent.displayName,
47
+ style: TextStyle(
48
+ fontSize: 17,
49
+ fontWeight: FontWeight.w700,
50
+ ),
51
+ ),
52
+ ),
53
+ if (agent.isDefault)
54
+ _StatusPill(label: 'Default', color: _accentHover),
55
+ const SizedBox(width: 8),
56
+ _StatusPill(
57
+ label: agent.status,
58
+ color: agent.status == 'active'
59
+ ? _success
60
+ : _textSecondary,
61
+ ),
62
+ ],
63
+ ),
64
+ const SizedBox(height: 8),
65
+ Text(
66
+ '@${agent.slug}',
67
+ style: TextStyle(color: _textSecondary),
68
+ ),
69
+ if (agent.description.trim().isNotEmpty) ...<Widget>[
70
+ const SizedBox(height: 10),
71
+ Text(agent.description),
72
+ ],
73
+ if (agent.responsibilities.trim().isNotEmpty) ...<Widget>[
74
+ const SizedBox(height: 10),
75
+ Text(
76
+ agent.responsibilities,
77
+ style: TextStyle(color: _textSecondary),
78
+ ),
79
+ ],
80
+ const SizedBox(height: 10),
81
+ Text(
82
+ _communicationSummary(controller, agent),
83
+ style: TextStyle(color: _textSecondary),
84
+ ),
85
+ const SizedBox(height: 14),
86
+ Wrap(
87
+ spacing: 8,
88
+ runSpacing: 8,
89
+ children: <Widget>[
90
+ OutlinedButton(
91
+ onPressed: () => controller.switchAgent(agent.id),
92
+ child: Text(
93
+ controller.selectedAgentId == agent.id
94
+ ? 'Selected'
95
+ : 'Switch',
96
+ ),
97
+ ),
98
+ OutlinedButton(
99
+ onPressed: () => openAgentEditor(
100
+ context,
101
+ controller,
102
+ agent: agent,
103
+ ),
104
+ child: Text('Edit'),
105
+ ),
106
+ if (!agent.isDefault)
107
+ OutlinedButton(
108
+ onPressed: () =>
109
+ controller.makeAgentDefault(agent.id),
110
+ child: Text('Make default'),
111
+ ),
112
+ if (!agent.isMain && !agent.isDefault)
113
+ TextButton(
114
+ onPressed: () => _confirmDelete(
115
+ context,
116
+ title: 'Archive agent?',
117
+ message:
118
+ 'This hides "${agent.displayName}" from routing and selection.',
119
+ onConfirm: () =>
120
+ controller.archiveAgent(agent.id),
121
+ ),
122
+ child: Text('Archive'),
123
+ ),
124
+ ],
125
+ ),
126
+ ],
127
+ ),
128
+ ),
129
+ ),
130
+ ),
131
+ ),
132
+ ],
133
+ );
134
+ }
135
+
136
+ static Future<void> openAgentEditor(
137
+ BuildContext context,
138
+ NeoAgentController controller, {
139
+ AgentProfile? agent,
140
+ }) async {
141
+ final nameController = TextEditingController(
142
+ text: agent?.displayName ?? '',
143
+ );
144
+ final slugController = TextEditingController(text: agent?.slug ?? '');
145
+ final descriptionController = TextEditingController(
146
+ text: agent?.description ?? '',
147
+ );
148
+ final responsibilitiesController = TextEditingController(
149
+ text: agent?.responsibilities ?? '',
150
+ );
151
+ final instructionsController = TextEditingController(
152
+ text: agent?.instructions ?? '',
153
+ );
154
+ var status = agent?.status ?? 'active';
155
+ var canDelegate = agent?.canDelegate ?? false;
156
+ var canBeDelegatedTo = agent?.canBeDelegatedTo ?? true;
157
+ var restrictDelegateTargets =
158
+ agent != null && agent.delegateTargets.isNotEmpty;
159
+ final delegateTargets = <String>{...?agent?.delegateTargets};
160
+
161
+ await showDialog<void>(
162
+ context: context,
163
+ builder: (context) {
164
+ return StatefulBuilder(
165
+ builder: (context, setLocalState) {
166
+ return AlertDialog(
167
+ backgroundColor: _bgCard,
168
+ title: Text(agent == null ? 'Add Agent' : 'Edit Agent'),
169
+ content: SizedBox(
170
+ width: 720,
171
+ child: SingleChildScrollView(
172
+ child: Column(
173
+ mainAxisSize: MainAxisSize.min,
174
+ children: <Widget>[
175
+ TextField(
176
+ controller: nameController,
177
+ decoration: const InputDecoration(labelText: 'Name'),
178
+ onChanged: (value) {
179
+ if (agent == null && slugController.text.isEmpty) {
180
+ slugController.text = value
181
+ .trim()
182
+ .toLowerCase()
183
+ .replaceAll(RegExp(r'[^a-z0-9_-]+'), '-')
184
+ .replaceAll(RegExp(r'^-+|-+$'), '');
185
+ }
186
+ },
187
+ ),
188
+ const SizedBox(height: 12),
189
+ TextField(
190
+ controller: slugController,
191
+ decoration: const InputDecoration(labelText: 'Slug'),
192
+ ),
193
+ const SizedBox(height: 12),
194
+ TextField(
195
+ controller: descriptionController,
196
+ decoration: const InputDecoration(
197
+ labelText: 'Description',
198
+ ),
199
+ ),
200
+ const SizedBox(height: 12),
201
+ TextField(
202
+ controller: responsibilitiesController,
203
+ minLines: 3,
204
+ maxLines: 6,
205
+ decoration: const InputDecoration(
206
+ labelText: 'Responsibilities',
207
+ ),
208
+ ),
209
+ const SizedBox(height: 12),
210
+ TextField(
211
+ controller: instructionsController,
212
+ minLines: 4,
213
+ maxLines: 8,
214
+ decoration: const InputDecoration(
215
+ labelText: 'Instructions',
216
+ ),
217
+ ),
218
+ const SizedBox(height: 12),
219
+ DropdownButtonFormField<String>(
220
+ initialValue: status,
221
+ decoration: const InputDecoration(labelText: 'Status'),
222
+ items: const <DropdownMenuItem<String>>[
223
+ DropdownMenuItem(
224
+ value: 'active',
225
+ child: Text('Active'),
226
+ ),
227
+ DropdownMenuItem(
228
+ value: 'paused',
229
+ child: Text('Paused'),
230
+ ),
231
+ ],
232
+ onChanged: (value) =>
233
+ setLocalState(() => status = value ?? 'active'),
234
+ ),
235
+ const SizedBox(height: 16),
236
+ Align(
237
+ alignment: Alignment.centerLeft,
238
+ child: Text(
239
+ 'Agent communication',
240
+ style: Theme.of(context).textTheme.titleSmall,
241
+ ),
242
+ ),
243
+ const SizedBox(height: 8),
244
+ SwitchListTile(
245
+ contentPadding: EdgeInsets.zero,
246
+ value: canDelegate,
247
+ title: Text('Can delegate tasks to other agents'),
248
+ subtitle: Text(
249
+ 'Use this for orchestrator agents. Leave off for isolated work bots that should finish direct messages themselves.',
250
+ ),
251
+ onChanged: (value) =>
252
+ setLocalState(() => canDelegate = value),
253
+ ),
254
+ SwitchListTile(
255
+ contentPadding: EdgeInsets.zero,
256
+ value: canBeDelegatedTo,
257
+ title: Text('Can receive delegated tasks'),
258
+ subtitle: Text(
259
+ 'Turn this off to keep this agent fully separate from other agents.',
260
+ ),
261
+ onChanged: (value) =>
262
+ setLocalState(() => canBeDelegatedTo = value),
263
+ ),
264
+ if (canDelegate) ...<Widget>[
265
+ SwitchListTile(
266
+ contentPadding: EdgeInsets.zero,
267
+ value: restrictDelegateTargets,
268
+ title: Text('Restrict delegation targets'),
269
+ subtitle: Text(
270
+ restrictDelegateTargets
271
+ ? 'Only selected agents can receive tasks from this agent.'
272
+ : 'This agent can delegate to any eligible receiving agent.',
273
+ ),
274
+ onChanged: (value) => setLocalState(() {
275
+ restrictDelegateTargets = value;
276
+ if (!value) delegateTargets.clear();
277
+ }),
278
+ ),
279
+ if (restrictDelegateTargets) ...<Widget>[
280
+ const SizedBox(height: 6),
281
+ Align(
282
+ alignment: Alignment.centerLeft,
283
+ child: Wrap(
284
+ spacing: 8,
285
+ runSpacing: 8,
286
+ children: controller.agentProfiles
287
+ .where((target) => target.id != agent?.id)
288
+ .map((target) {
289
+ final selected = delegateTargets.contains(
290
+ target.id,
291
+ );
292
+ return FilterChip(
293
+ label: Text(target.displayName),
294
+ selected: selected,
295
+ onSelected: (value) => setLocalState(() {
296
+ if (value) {
297
+ delegateTargets.add(target.id);
298
+ } else {
299
+ delegateTargets.remove(target.id);
300
+ }
301
+ }),
302
+ );
303
+ })
304
+ .toList(),
305
+ ),
306
+ ),
307
+ ],
308
+ ],
309
+ ],
310
+ ),
311
+ ),
312
+ ),
313
+ actions: <Widget>[
314
+ TextButton(
315
+ onPressed: () => Navigator.of(context).pop(),
316
+ child: Text('Cancel'),
317
+ ),
318
+ FilledButton(
319
+ onPressed: () async {
320
+ final saved = await controller.saveAgentProfile(
321
+ id: agent?.id,
322
+ displayName: nameController.text.trim(),
323
+ slug: slugController.text.trim(),
324
+ description: descriptionController.text.trim(),
325
+ responsibilities: responsibilitiesController.text.trim(),
326
+ instructions: instructionsController.text.trim(),
327
+ status: status,
328
+ canDelegate: canDelegate,
329
+ canBeDelegatedTo: canBeDelegatedTo,
330
+ delegateTargets: restrictDelegateTargets
331
+ ? delegateTargets.toList(growable: false)
332
+ : const <String>[],
333
+ );
334
+ if (saved && context.mounted) {
335
+ Navigator.of(context).pop();
336
+ }
337
+ },
338
+ child: Text('Save'),
339
+ ),
340
+ ],
341
+ );
342
+ },
343
+ );
344
+ },
345
+ );
346
+ }
347
+
348
+ static String _communicationSummary(
349
+ NeoAgentController controller,
350
+ AgentProfile agent,
351
+ ) {
352
+ final parts = <String>[];
353
+ parts.add(
354
+ agent.canDelegate
355
+ ? (agent.delegatesToAnyEligibleAgent
356
+ ? 'Can delegate to any receiving agent'
357
+ : 'Can delegate to ${agent.delegateTargets.map(controller.agentLabelFor).join(', ')}')
358
+ : 'Handles direct tasks itself',
359
+ );
360
+ parts.add(
361
+ agent.canBeDelegatedTo
362
+ ? 'can receive delegated tasks'
363
+ : 'cannot receive delegated tasks',
364
+ );
365
+ return 'Agent communication: ${parts.join('; ')}.';
366
+ }
367
+ }
368
+
369
+ class McpPanel extends StatelessWidget {
370
+ const McpPanel({super.key, required this.controller});
371
+
372
+ final NeoAgentController controller;
373
+
374
+ @override
375
+ Widget build(BuildContext context) {
376
+ return ListView(
377
+ padding: _pagePadding(context),
378
+ children: <Widget>[
379
+ _PageTitle(
380
+ title: 'MCP',
381
+ subtitle: 'Configured MCP servers and live server status.',
382
+ trailing: FilledButton.icon(
383
+ onPressed: () => _openMcpEditor(context),
384
+ icon: Icon(Icons.add),
385
+ label: Text('Add Server'),
386
+ ),
387
+ ),
388
+ if (controller.mcpServers.isEmpty)
389
+ const _EmptyCard(
390
+ title: 'No MCP servers configured',
391
+ subtitle: 'Add an MCP server URL and choose an auth method.',
392
+ )
393
+ else
394
+ ...controller.mcpServers.map(
395
+ (server) => Padding(
396
+ padding: const EdgeInsets.only(bottom: 14),
397
+ child: Card(
398
+ child: Padding(
399
+ padding: const EdgeInsets.all(18),
400
+ child: Column(
401
+ crossAxisAlignment: CrossAxisAlignment.start,
402
+ children: <Widget>[
403
+ Row(
404
+ children: <Widget>[
405
+ Expanded(
406
+ child: Text(
407
+ server.name,
408
+ style: TextStyle(
409
+ fontSize: 16,
410
+ fontWeight: FontWeight.w700,
411
+ ),
412
+ ),
413
+ ),
414
+ _StatusPill(
415
+ label: server.status,
416
+ color: server.status == 'running'
417
+ ? _success
418
+ : _textSecondary,
419
+ ),
420
+ ],
421
+ ),
422
+ const SizedBox(height: 10),
423
+ Text(
424
+ server.command,
425
+ style: TextStyle(
426
+ fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
427
+ color: _textSecondary,
428
+ ),
429
+ ),
430
+ const SizedBox(height: 12),
431
+ Wrap(
432
+ spacing: 10,
433
+ runSpacing: 10,
434
+ children: <Widget>[
435
+ _MetaPill(
436
+ label: server.enabled ? 'Enabled' : 'Disabled',
437
+ icon: Icons.toggle_on_outlined,
438
+ ),
439
+ _MetaPill(
440
+ label: '${server.toolCount} tools',
441
+ icon: Icons.build_outlined,
442
+ ),
443
+ _MetaPill(
444
+ label: server.authMethodLabel,
445
+ icon: Icons.lock_outline,
446
+ ),
447
+ _MetaPill(
448
+ label:
449
+ 'Agent: ${controller.agentLabelFor(server.agentId)}',
450
+ icon: Icons.smart_toy_outlined,
451
+ ),
452
+ ],
453
+ ),
454
+ const SizedBox(height: 14),
455
+ Wrap(
456
+ spacing: 10,
457
+ runSpacing: 10,
458
+ children: <Widget>[
459
+ OutlinedButton(
460
+ onPressed: () =>
461
+ _openMcpEditor(context, server: server),
462
+ child: Text('Edit'),
463
+ ),
464
+ if (server.status == 'running')
465
+ FilledButton(
466
+ onPressed: () =>
467
+ controller.stopMcpServer(server.id),
468
+ child: Text('Stop'),
469
+ )
470
+ else
471
+ FilledButton(
472
+ onPressed: () =>
473
+ controller.startMcpServer(server.id),
474
+ child: Text('Start'),
475
+ ),
476
+ OutlinedButton(
477
+ onPressed: () => _confirmDelete(
478
+ context,
479
+ title: 'Delete MCP server?',
480
+ message:
481
+ 'This will remove "${server.name}" from the server list.',
482
+ onConfirm: () =>
483
+ controller.deleteMcpServer(server.id),
484
+ ),
485
+ child: Text('Delete'),
486
+ ),
487
+ ],
488
+ ),
489
+ ],
490
+ ),
491
+ ),
492
+ ),
493
+ ),
494
+ ),
495
+ ],
496
+ );
497
+ }
498
+
499
+ Future<void> _openMcpEditor(
500
+ BuildContext context, {
501
+ McpServerItem? server,
502
+ }) async {
503
+ final nameController = TextEditingController(text: server?.name ?? '');
504
+ final urlController = TextEditingController(text: server?.command ?? '');
505
+ final auth = _jsonMap(server?.config['auth']);
506
+ String authType = auth['type']?.toString().ifEmpty('none') ?? 'none';
507
+ final tokenController = TextEditingController(
508
+ text: auth['token']?.toString() ?? '',
509
+ );
510
+ final clientIdController = TextEditingController(
511
+ text: auth['clientId']?.toString() ?? '',
512
+ );
513
+ final authServerUrlController = TextEditingController(
514
+ text: auth['authServerUrl']?.toString() ?? '',
515
+ );
516
+ var enabled = server?.enabled ?? true;
517
+ var selectedAgentId = server?.agentId ?? controller.selectedAgentId;
518
+ if (selectedAgentId != null &&
519
+ !controller.agentProfiles.any((agent) => agent.id == selectedAgentId)) {
520
+ selectedAgentId = controller.selectedAgentId;
521
+ }
522
+ if (selectedAgentId != null &&
523
+ !controller.agentProfiles.any((agent) => agent.id == selectedAgentId)) {
524
+ selectedAgentId = controller.agentProfiles.isEmpty
525
+ ? null
526
+ : controller.agentProfiles.first.id;
527
+ }
528
+
529
+ await showDialog<void>(
530
+ context: context,
531
+ builder: (context) {
532
+ return StatefulBuilder(
533
+ builder: (context, setLocalState) {
534
+ return AlertDialog(
535
+ backgroundColor: _bgCard,
536
+ title: Text(
537
+ server == null ? 'Add MCP Server' : 'Edit MCP Server',
538
+ ),
539
+ content: SizedBox(
540
+ width: 720,
541
+ child: SingleChildScrollView(
542
+ child: Column(
543
+ mainAxisSize: MainAxisSize.min,
544
+ children: <Widget>[
545
+ TextField(
546
+ controller: nameController,
547
+ decoration: const InputDecoration(labelText: 'Name'),
548
+ ),
549
+ const SizedBox(height: 12),
550
+ TextField(
551
+ controller: urlController,
552
+ decoration: const InputDecoration(
553
+ labelText: 'MCP Server URL',
554
+ ),
555
+ ),
556
+ const SizedBox(height: 12),
557
+ DropdownButtonFormField<String>(
558
+ initialValue: authType,
559
+ decoration: const InputDecoration(
560
+ labelText: 'Auth Method',
561
+ ),
562
+ items: const <DropdownMenuItem<String>>[
563
+ DropdownMenuItem(value: 'none', child: Text('None')),
564
+ DropdownMenuItem(
565
+ value: 'bearer',
566
+ child: Text('Bearer Token'),
567
+ ),
568
+ DropdownMenuItem(
569
+ value: 'oauth',
570
+ child: Text('OAuth'),
571
+ ),
572
+ ],
573
+ onChanged: (value) {
574
+ if (value != null) {
575
+ setLocalState(() => authType = value);
576
+ }
577
+ },
578
+ ),
579
+ if (authType == 'bearer') ...<Widget>[
580
+ const SizedBox(height: 12),
581
+ TextField(
582
+ controller: tokenController,
583
+ obscureText: true,
584
+ decoration: const InputDecoration(
585
+ labelText: 'Bearer Token',
586
+ ),
587
+ ),
588
+ ],
589
+ if (authType == 'oauth') ...<Widget>[
590
+ const SizedBox(height: 12),
591
+ TextField(
592
+ controller: clientIdController,
593
+ decoration: const InputDecoration(
594
+ labelText: 'OAuth Client ID',
595
+ ),
596
+ ),
597
+ const SizedBox(height: 12),
598
+ TextField(
599
+ controller: authServerUrlController,
600
+ decoration: const InputDecoration(
601
+ labelText: 'Auth Server URL',
602
+ ),
603
+ ),
604
+ ],
605
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
606
+ const SizedBox(height: 12),
607
+ DropdownButtonFormField<String>(
608
+ initialValue: selectedAgentId,
609
+ isExpanded: true,
610
+ decoration: const InputDecoration(
611
+ labelText: 'Assigned Agent',
612
+ ),
613
+ items: controller.agentProfiles
614
+ .map(
615
+ (agent) => DropdownMenuItem<String>(
616
+ value: agent.id,
617
+ child: Text(agent.label),
618
+ ),
619
+ )
620
+ .toList(),
621
+ onChanged: (value) =>
622
+ setLocalState(() => selectedAgentId = value),
623
+ ),
624
+ ],
625
+ const SizedBox(height: 12),
626
+ Align(
627
+ alignment: Alignment.centerLeft,
628
+ child: Text(
629
+ 'Matches the old NeoAgent MCP flow: URL plus auth method.',
630
+ style: TextStyle(color: _textSecondary),
631
+ ),
632
+ ),
633
+ const SizedBox(height: 12),
634
+ SwitchListTile(
635
+ value: enabled,
636
+ contentPadding: EdgeInsets.zero,
637
+ title: Text('Enabled'),
638
+ onChanged: (value) =>
639
+ setLocalState(() => enabled = value),
640
+ ),
641
+ const SizedBox(height: 4),
642
+ Align(
643
+ alignment: Alignment.centerLeft,
644
+ child: Text(
645
+ 'Start the server later from the list once the config is saved.',
646
+ style: TextStyle(color: _textSecondary, fontSize: 12),
647
+ ),
648
+ ),
649
+ ],
650
+ ),
651
+ ),
652
+ ),
653
+ actions: <Widget>[
654
+ TextButton(
655
+ onPressed: () => Navigator.of(context).pop(),
656
+ child: Text('Cancel'),
657
+ ),
658
+ FilledButton(
659
+ onPressed: () async {
660
+ final config = <String, dynamic>{
661
+ 'auth': <String, dynamic>{
662
+ 'type': authType,
663
+ if (authType == 'bearer' &&
664
+ tokenController.text.trim().isNotEmpty)
665
+ 'token': tokenController.text.trim(),
666
+ if (authType == 'oauth' &&
667
+ clientIdController.text.trim().isNotEmpty)
668
+ 'clientId': clientIdController.text.trim(),
669
+ if (authType == 'oauth' &&
670
+ authServerUrlController.text.trim().isNotEmpty)
671
+ 'authServerUrl': authServerUrlController.text.trim(),
672
+ },
673
+ };
674
+ final saved = await controller.saveMcpServer(
675
+ id: server?.id,
676
+ name: nameController.text.trim(),
677
+ command: urlController.text.trim(),
678
+ config: config,
679
+ enabled: enabled,
680
+ agentId: selectedAgentId,
681
+ );
682
+ if (!context.mounted) {
683
+ return;
684
+ }
685
+ if (saved) {
686
+ Navigator.of(context).pop();
687
+ } else {
688
+ ScaffoldMessenger.of(context).showSnackBar(
689
+ SnackBar(
690
+ content: Text(
691
+ controller.errorMessage ??
692
+ 'Failed to save MCP server.',
693
+ ),
694
+ ),
695
+ );
696
+ }
697
+ },
698
+ child: Text('Save'),
699
+ ),
700
+ ],
701
+ );
702
+ },
703
+ );
704
+ },
705
+ );
706
+ }
707
+ }
708
+
709
+ class HealthPanel extends StatelessWidget {
710
+ const HealthPanel({super.key, required this.controller});
711
+
712
+ final NeoAgentController controller;
713
+
714
+ @override
715
+ Widget build(BuildContext context) {
716
+ final deviceStatus = controller.deviceHealthStatus;
717
+ final backendStatus = controller.backendHealthStatus;
718
+ final metrics = _jsonList(
719
+ backendStatus?['metrics'],
720
+ fallbackToMapValues: true,
721
+ );
722
+ final lastRun = _jsonMap(backendStatus?['lastRun']);
723
+ final lastNonEmptyRun = _jsonMap(backendStatus?['lastNonEmptyRun']);
724
+ final lastSummary = _jsonMap(lastRun['summary']);
725
+ final lastNonEmptySummary = _jsonMap(lastNonEmptyRun['summary']);
726
+ final lastRunRecordCount = _asInt(lastRun['record_count']);
727
+ final lastSyncEmpty = lastRun.isNotEmpty && lastRunRecordCount == 0;
728
+ final lastWindowEnd = _parseOptionalTimestamp(
729
+ lastRun['sync_window_end']?.toString(),
730
+ );
731
+ final lastNonEmptyWindowEnd = _parseOptionalTimestamp(
732
+ lastNonEmptyRun['sync_window_end']?.toString(),
733
+ );
734
+
735
+ return ListView(
736
+ padding: _pagePadding(context),
737
+ children: <Widget>[
738
+ const _PageTitle(
739
+ title: 'Health',
740
+ subtitle: 'Health Connect sync status and stored backend metrics.',
741
+ ),
742
+ if (controller.errorMessage != null) ...<Widget>[
743
+ _InlineError(message: controller.errorMessage!),
744
+ const SizedBox(height: 16),
745
+ ],
746
+ Row(
747
+ children: <Widget>[
748
+ Expanded(
749
+ child: _OverviewCard(
750
+ title: 'Device access',
751
+ value: deviceStatus == null
752
+ ? 'Checking...'
753
+ : !deviceStatus.available
754
+ ? 'Unavailable'
755
+ : deviceStatus.permissionsGranted
756
+ ? 'Ready'
757
+ : 'Permissions needed',
758
+ helper:
759
+ deviceStatus?.message ??
760
+ 'Reads steps, heart rate, sleep, exercise, and weight.',
761
+ ),
762
+ ),
763
+ const SizedBox(width: 12),
764
+ Expanded(
765
+ child: _OverviewCard(
766
+ title: 'Backend sync',
767
+ value: lastRun.isEmpty
768
+ ? 'No sync yet'
769
+ : lastSyncEmpty
770
+ ? 'No new data'
771
+ : '$lastRunRecordCount records',
772
+ helper: lastRun.isEmpty
773
+ ? 'Sync once to seed your backend.'
774
+ : lastWindowEnd == null
775
+ ? 'Last window end is unknown.'
776
+ : 'Last window ended ${_formatTimestamp(lastWindowEnd)}',
777
+ ),
778
+ ),
779
+ ],
780
+ ),
781
+ const SizedBox(height: 16),
782
+ Card(
783
+ child: Padding(
784
+ padding: const EdgeInsets.all(20),
785
+ child: Wrap(
786
+ spacing: 12,
787
+ runSpacing: 12,
788
+ children: <Widget>[
789
+ OutlinedButton.icon(
790
+ onPressed: controller.requestHealthPermissions,
791
+ icon: Icon(Icons.health_and_safety_outlined),
792
+ label: Text('Request permissions'),
793
+ ),
794
+ FilledButton.icon(
795
+ onPressed: controller.isSyncingHealth
796
+ ? null
797
+ : controller.syncHealthNow,
798
+ style: FilledButton.styleFrom(
799
+ backgroundColor: _accentHover,
800
+ foregroundColor: _bgPrimary,
801
+ ),
802
+ icon: controller.isSyncingHealth
803
+ ? const SizedBox.square(
804
+ dimension: 16,
805
+ child: CircularProgressIndicator(strokeWidth: 2),
806
+ )
807
+ : Icon(Icons.sync),
808
+ label: Text('Sync now'),
809
+ ),
810
+ _MetaPill(
811
+ label: 'Background sync stays scheduled on Android',
812
+ icon: Icons.sync_lock_outlined,
813
+ ),
814
+ ],
815
+ ),
816
+ ),
817
+ ),
818
+ const SizedBox(height: 16),
819
+ Card(
820
+ child: Padding(
821
+ padding: const EdgeInsets.all(20),
822
+ child: Column(
823
+ crossAxisAlignment: CrossAxisAlignment.start,
824
+ children: <Widget>[
825
+ const _SectionTitle('Last Sync Summary'),
826
+ const SizedBox(height: 12),
827
+ if (lastSummary.isEmpty)
828
+ Text(
829
+ 'No detailed sync summary yet.',
830
+ style: TextStyle(color: _textSecondary),
831
+ )
832
+ else ...<Widget>[
833
+ if (lastSyncEmpty && metrics.isNotEmpty)
834
+ Padding(
835
+ padding: const EdgeInsets.only(bottom: 12),
836
+ child: Text(
837
+ lastWindowEnd == null
838
+ ? 'The latest sync completed successfully but did not find any new Health Connect records. Stored metrics below came from earlier syncs.'
839
+ : 'The latest sync window ended ${_formatTimestamp(lastWindowEnd)} and did not find any new Health Connect records. Stored metrics below came from earlier syncs.',
840
+ style: TextStyle(color: _textSecondary),
841
+ ),
842
+ ),
843
+ _buildHealthSummaryPills(lastSummary),
844
+ ],
845
+ if (lastSyncEmpty &&
846
+ lastNonEmptySummary.isNotEmpty) ...<Widget>[
847
+ const SizedBox(height: 18),
848
+ Text(
849
+ lastNonEmptyWindowEnd == null
850
+ ? 'Last non-empty sync'
851
+ : 'Last non-empty sync · ${_formatTimestamp(lastNonEmptyWindowEnd)}',
852
+ style: TextStyle(
853
+ color: _textSecondary,
854
+ fontWeight: FontWeight.w700,
855
+ ),
856
+ ),
857
+ const SizedBox(height: 12),
858
+ _buildHealthSummaryPills(lastNonEmptySummary),
859
+ ],
860
+ const SizedBox(height: 18),
861
+ const _SectionTitle('Stored Metrics'),
862
+ const SizedBox(height: 12),
863
+ if (metrics.isEmpty)
864
+ Text('No health samples stored yet.')
865
+ else
866
+ Wrap(
867
+ spacing: 10,
868
+ runSpacing: 10,
869
+ children: metrics.whereType<Map<dynamic, dynamic>>().map((
870
+ map,
871
+ ) {
872
+ return _MetaPill(
873
+ icon: Icons.favorite_border,
874
+ label:
875
+ '${map['metricType']} · ${map['sampleCount']} samples',
876
+ );
877
+ }).toList(),
878
+ ),
879
+ ],
880
+ ),
881
+ ),
882
+ ),
883
+ ],
884
+ );
885
+ }
886
+ }