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,4851 @@
1
+ part of 'main.dart';
2
+
3
+ class LogsPanel extends StatefulWidget {
4
+ const LogsPanel({super.key, required this.controller});
5
+
6
+ final NeoAgentController controller;
7
+
8
+ @override
9
+ State<LogsPanel> createState() => _LogsPanelState();
10
+ }
11
+
12
+ class _LogsPanelState extends State<LogsPanel> {
13
+ static const JsonEncoder _debugJsonEncoder = JsonEncoder.withIndent(' ');
14
+ bool _isExportingRecentMessages = false;
15
+
16
+ String _recentLogsText() =>
17
+ widget.controller.logs.map((log) => log.clipboardLine).join('\n');
18
+
19
+ String _prettyJson(Object? value) => _debugJsonEncoder.convert(value);
20
+
21
+ Future<Map<String, dynamic>?> _buildRunExport(
22
+ String runId,
23
+ Map<String, Map<String, dynamic>> cache,
24
+ ) async {
25
+ if (runId.trim().isEmpty) {
26
+ return null;
27
+ }
28
+ if (cache.containsKey(runId)) {
29
+ return cache[runId];
30
+ }
31
+ try {
32
+ final detail = await widget.controller.fetchRunDetail(runId);
33
+ final payload = <String, dynamic>{
34
+ 'run': <String, dynamic>{
35
+ 'id': detail.run.id,
36
+ 'title': detail.run.title,
37
+ 'status': detail.run.status,
38
+ 'statusLabel': detail.run.statusLabel,
39
+ 'triggerSource': detail.run.triggerSource,
40
+ 'triggerLabel': detail.run.triggerLabel,
41
+ 'model': detail.run.model,
42
+ 'createdAt': detail.run.createdAt.toIso8601String(),
43
+ 'completedAt': detail.run.completedAt?.toIso8601String(),
44
+ 'durationLabel': detail.run.durationLabel,
45
+ 'totalTokens': detail.run.totalTokens,
46
+ 'error': detail.run.error,
47
+ },
48
+ 'response': detail.response,
49
+ 'steps': detail.steps
50
+ .map(
51
+ (step) => <String, dynamic>{
52
+ 'id': step.id,
53
+ 'index': step.index,
54
+ 'displayIndex': step.displayIndex,
55
+ 'type': step.type,
56
+ 'status': step.status,
57
+ 'description': step.description,
58
+ 'toolName': step.toolName,
59
+ 'toolInput': step.toolInput,
60
+ 'result': step.result,
61
+ 'error': step.error,
62
+ 'tokensUsed': step.tokensUsed,
63
+ 'startedAt': step.startedAt?.toIso8601String(),
64
+ 'completedAt': step.completedAt?.toIso8601String(),
65
+ },
66
+ )
67
+ .toList(),
68
+ };
69
+ cache[runId] = payload;
70
+ return payload;
71
+ } catch (error) {
72
+ final payload = <String, dynamic>{
73
+ 'runId': runId,
74
+ 'error': error.toString(),
75
+ };
76
+ cache[runId] = payload;
77
+ return payload;
78
+ }
79
+ }
80
+
81
+ Future<String> _buildRecentMessagesExport() async {
82
+ final controller = widget.controller;
83
+ final recentMessages = controller.visibleChatMessages.reversed
84
+ .take(5)
85
+ .toList()
86
+ .reversed
87
+ .toList();
88
+ final runCache = <String, Map<String, dynamic>>{};
89
+
90
+ final messages = <Map<String, dynamic>>[];
91
+ for (final entry in recentMessages) {
92
+ final runId = entry.runId?.trim() ?? '';
93
+ messages.add(<String, dynamic>{
94
+ 'id': entry.id,
95
+ 'role': entry.role,
96
+ 'content': entry.content,
97
+ 'platform': entry.platform,
98
+ 'senderName': entry.senderName,
99
+ 'createdAt': entry.createdAt.toIso8601String(),
100
+ 'transient': entry.transient,
101
+ 'runId': runId.isEmpty ? null : runId,
102
+ 'metadata': entry.metadata,
103
+ 'toolCalls': entry.toolCalls,
104
+ if (runId.isNotEmpty)
105
+ 'runDetail': await _buildRunExport(runId, runCache),
106
+ });
107
+ }
108
+
109
+ final export = <String, dynamic>{
110
+ 'generatedAt': DateTime.now().toIso8601String(),
111
+ 'kind': 'recent_chat_export',
112
+ 'messageCount': messages.length,
113
+ 'agent': <String, dynamic>{
114
+ 'id': controller.selectedAgentId,
115
+ 'label': controller.activeAgentLabel,
116
+ },
117
+ 'liveRun': controller.activeRun == null
118
+ ? null
119
+ : <String, dynamic>{
120
+ 'runId': controller.activeRun!.runId,
121
+ 'title': controller.activeRun!.title,
122
+ 'model': controller.activeRun!.model,
123
+ 'phase': controller.activeRun!.phase,
124
+ 'iteration': controller.activeRun!.iteration,
125
+ 'pendingSteeringCount':
126
+ controller.activeRun!.pendingSteeringCount,
127
+ 'triggerSource': controller.activeRun!.triggerSource,
128
+ },
129
+ 'liveToolEvents': controller.toolEvents
130
+ .map(
131
+ (event) => <String, dynamic>{
132
+ 'id': event.id,
133
+ 'toolName': event.toolName,
134
+ 'type': event.type,
135
+ 'status': event.status,
136
+ 'summary': event.summary,
137
+ },
138
+ )
139
+ .toList(),
140
+ 'messages': messages,
141
+ };
142
+ return _prettyJson(export);
143
+ }
144
+
145
+ String _buildDebugInfo() {
146
+ final controller = widget.controller;
147
+ final now = DateTime.now().toIso8601String();
148
+ final versionInfo = controller.versionInfo;
149
+ final backendStatus = controller.backendHealthStatus;
150
+ final lastRun = _jsonMap(backendStatus?['lastRun']);
151
+ final lastNonEmptyRun = _jsonMap(backendStatus?['lastNonEmptyRun']);
152
+
153
+ final snapshot = <String, dynamic>{
154
+ 'generatedAt': now,
155
+ 'platform': kIsWeb ? 'web' : defaultTargetPlatform.name,
156
+ 'session': <String, dynamic>{
157
+ 'backendUrl': controller.backendUrl,
158
+ 'authenticated': controller.isAuthenticated,
159
+ 'socketConnected': controller.socketConnected,
160
+ 'selectedSection': controller.selectedSection.label,
161
+ 'account': controller.accountLabel,
162
+ },
163
+ 'version': <String, dynamic>{
164
+ 'name': versionInfo?['name'],
165
+ 'version': versionInfo?['version'],
166
+ 'packageVersion': versionInfo?['packageVersion'],
167
+ 'gitVersion': versionInfo?['gitVersion'],
168
+ 'gitBranch': versionInfo?['gitBranch'],
169
+ 'gitSha': versionInfo?['gitSha'],
170
+ 'deploymentMode':
171
+ versionInfo?['deploymentMode'] ??
172
+ controller.updateStatus.deploymentMode,
173
+ 'deploymentProfile':
174
+ versionInfo?['deploymentProfile'] ??
175
+ controller.updateStatus.deploymentProfile,
176
+ 'allowSelfUpdate':
177
+ versionInfo?['allowSelfUpdate'] ??
178
+ controller.updateStatus.allowSelfUpdate,
179
+ 'releaseChannel':
180
+ versionInfo?['releaseChannel'] ??
181
+ controller.updateStatus.releaseChannel,
182
+ 'targetBranch':
183
+ versionInfo?['targetBranch'] ??
184
+ controller.updateStatus.targetBranch,
185
+ 'npmDistTag':
186
+ versionInfo?['npmDistTag'] ?? controller.updateStatus.npmDistTag,
187
+ },
188
+ 'ai': <String, dynamic>{
189
+ 'defaultChatModel': controller.defaultChatModel,
190
+ 'defaultSubagentModel': controller.defaultSubagentModel,
191
+ 'fallbackModel': controller.fallbackModel,
192
+ 'smarterSelector': controller.smarterSelector,
193
+ 'enabledModelCount': controller.enabledModelIds.length,
194
+ 'availableModelCount': controller.supportedModels
195
+ .where((model) => model.available)
196
+ .length,
197
+ 'providerStatus': controller.aiProviders
198
+ .map(
199
+ (provider) => <String, dynamic>{
200
+ 'id': provider.id,
201
+ 'enabled': provider.enabled,
202
+ 'available': provider.available,
203
+ 'status': provider.status,
204
+ 'statusLabel': provider.statusLabel,
205
+ 'modelCount': provider.modelCount,
206
+ 'availableModelCount': provider.availableModelCount,
207
+ 'baseUrl': provider.supportsBaseUrl ? provider.baseUrl : null,
208
+ 'credentialConfigured': provider.credentialConfigured,
209
+ },
210
+ )
211
+ .toList(),
212
+ },
213
+ 'runtime': <String, dynamic>{
214
+ 'headlessBrowser': controller.headlessBrowser,
215
+ 'browserBackend': controller.browserBackend,
216
+ 'browserExtensionConnected': controller.browserExtensionConnected,
217
+ 'hasLiveRun': controller.hasLiveRun,
218
+ 'activeRun': controller.activeRun == null
219
+ ? null
220
+ : <String, dynamic>{
221
+ 'runId': controller.activeRun!.runId,
222
+ 'title': controller.activeRun!.title,
223
+ 'model': controller.activeRun!.model,
224
+ 'phase': controller.activeRun!.phase,
225
+ 'iteration': controller.activeRun!.iteration,
226
+ 'pendingSteeringCount':
227
+ controller.activeRun!.pendingSteeringCount,
228
+ 'triggerSource': controller.activeRun!.triggerSource,
229
+ },
230
+ },
231
+ 'updateStatus': <String, dynamic>{
232
+ 'state': controller.updateStatus.state,
233
+ 'progress': controller.updateStatus.progress,
234
+ 'message': controller.updateStatus.message,
235
+ 'deploymentProfile': controller.updateStatus.deploymentProfile,
236
+ 'versionBefore': controller.updateStatus.versionBefore,
237
+ 'versionAfter': controller.updateStatus.versionAfter,
238
+ 'installedVersion': controller.updateStatus.installedVersion,
239
+ 'backendVersion': controller.updateStatus.backendVersion,
240
+ 'runtimeValidationReady':
241
+ controller.updateStatus.runtimeValidationReady,
242
+ 'runtimeValidationIssues':
243
+ controller.updateStatus.runtimeValidationIssues,
244
+ 'releaseChannel': controller.updateStatus.releaseChannel,
245
+ 'targetBranch': controller.updateStatus.targetBranch,
246
+ 'npmDistTag': controller.updateStatus.npmDistTag,
247
+ 'changelog': controller.updateStatus.changelog,
248
+ 'updateLogs': controller.updateStatus.logs,
249
+ },
250
+ 'health': <String, dynamic>{
251
+ 'status': backendStatus?['status'],
252
+ 'timestamp': backendStatus?['timestamp'],
253
+ 'metricsCount': _jsonList(
254
+ backendStatus?['metrics'],
255
+ fallbackToMapValues: true,
256
+ ).length,
257
+ 'lastRun': lastRun.isEmpty
258
+ ? null
259
+ : <String, dynamic>{
260
+ 'startedAt': lastRun['started_at'],
261
+ 'completedAt': lastRun['completed_at'],
262
+ 'recordCount': lastRun['record_count'],
263
+ 'syncWindowEnd': lastRun['sync_window_end'],
264
+ 'summary': _jsonMap(lastRun['summary']),
265
+ },
266
+ 'lastNonEmptyRun': lastNonEmptyRun.isEmpty
267
+ ? null
268
+ : <String, dynamic>{
269
+ 'startedAt': lastNonEmptyRun['started_at'],
270
+ 'completedAt': lastNonEmptyRun['completed_at'],
271
+ 'recordCount': lastNonEmptyRun['record_count'],
272
+ 'syncWindowEnd': lastNonEmptyRun['sync_window_end'],
273
+ 'summary': _jsonMap(lastNonEmptyRun['summary']),
274
+ },
275
+ },
276
+ 'recentLogs': controller.logs
277
+ .map(
278
+ (log) => <String, dynamic>{
279
+ 'time': log.timeLabel,
280
+ 'type': log.type,
281
+ 'source': log.source,
282
+ 'message': log.message,
283
+ },
284
+ )
285
+ .toList(),
286
+ };
287
+
288
+ return ['NeoAgent debug info', _prettyJson(snapshot)].join('\n\n');
289
+ }
290
+
291
+ Future<void> _copyLogs() async {
292
+ final logsText = _recentLogsText();
293
+ if (logsText.trim().isEmpty) {
294
+ return;
295
+ }
296
+
297
+ await Clipboard.setData(ClipboardData(text: logsText));
298
+ if (!mounted) {
299
+ return;
300
+ }
301
+
302
+ ScaffoldMessenger.of(
303
+ context,
304
+ ).showSnackBar(const SnackBar(content: Text('Copied logs')));
305
+ }
306
+
307
+ Future<void> _copyDebugInfo() async {
308
+ await Clipboard.setData(ClipboardData(text: _buildDebugInfo()));
309
+ if (!mounted) {
310
+ return;
311
+ }
312
+
313
+ ScaffoldMessenger.of(
314
+ context,
315
+ ).showSnackBar(const SnackBar(content: Text('Copied debug info')));
316
+ }
317
+
318
+ Future<void> _exportRecentMessages() async {
319
+ if (_isExportingRecentMessages) {
320
+ return;
321
+ }
322
+ setState(() => _isExportingRecentMessages = true);
323
+ try {
324
+ final exportText = await _buildRecentMessagesExport();
325
+ await Clipboard.setData(ClipboardData(text: exportText));
326
+ if (!mounted) {
327
+ return;
328
+ }
329
+ ScaffoldMessenger.of(context).showSnackBar(
330
+ const SnackBar(content: Text('Copied export for the last 5 messages')),
331
+ );
332
+ } catch (error) {
333
+ if (!mounted) {
334
+ return;
335
+ }
336
+ ScaffoldMessenger.of(context).showSnackBar(
337
+ SnackBar(
338
+ content: Text(
339
+ 'Export failed: ${widget.controller.friendlyErrorMessage(error)}',
340
+ ),
341
+ ),
342
+ );
343
+ } finally {
344
+ if (mounted) {
345
+ setState(() => _isExportingRecentMessages = false);
346
+ }
347
+ }
348
+ }
349
+
350
+ @override
351
+ Widget build(BuildContext context) {
352
+ return ListView(
353
+ padding: _pagePadding(context),
354
+ children: <Widget>[
355
+ _PageTitle(
356
+ title: 'Logs',
357
+ subtitle:
358
+ 'Merged server and Flutter runtime logs for this app session.',
359
+ trailing: Wrap(
360
+ spacing: 12,
361
+ runSpacing: 12,
362
+ children: <Widget>[
363
+ OutlinedButton.icon(
364
+ onPressed: _isExportingRecentMessages
365
+ ? null
366
+ : _exportRecentMessages,
367
+ icon: _isExportingRecentMessages
368
+ ? const SizedBox.square(
369
+ dimension: 16,
370
+ child: CircularProgressIndicator(strokeWidth: 2),
371
+ )
372
+ : Icon(Icons.ios_share_outlined),
373
+ label: Text('Export last 5 messages'),
374
+ ),
375
+ OutlinedButton.icon(
376
+ onPressed: _copyDebugInfo,
377
+ icon: Icon(Icons.bug_report_outlined),
378
+ label: Text('Copy debug info'),
379
+ ),
380
+ OutlinedButton.icon(
381
+ onPressed: widget.controller.logs.isEmpty ? null : _copyLogs,
382
+ icon: Icon(Icons.copy_all_outlined),
383
+ label: Text('Copy logs'),
384
+ ),
385
+ OutlinedButton.icon(
386
+ onPressed: widget.controller.clearLogs,
387
+ icon: Icon(Icons.clear_all),
388
+ label: Text('Clear'),
389
+ ),
390
+ ],
391
+ ),
392
+ ),
393
+ Card(
394
+ child: Padding(
395
+ padding: const EdgeInsets.all(16),
396
+ child: widget.controller.logs.isEmpty
397
+ ? Text(
398
+ 'Waiting for server or Flutter log output…',
399
+ style: TextStyle(color: _textSecondary),
400
+ )
401
+ : Column(
402
+ children: widget.controller.logs.map((log) {
403
+ return Container(
404
+ width: double.infinity,
405
+ padding: const EdgeInsets.symmetric(vertical: 6),
406
+ decoration: BoxDecoration(
407
+ border: Border(bottom: BorderSide(color: _border)),
408
+ ),
409
+ child: Text.rich(
410
+ TextSpan(
411
+ children: <InlineSpan>[
412
+ TextSpan(
413
+ text: '[${log.timeLabel}] ',
414
+ style: TextStyle(color: _textMuted),
415
+ ),
416
+ TextSpan(
417
+ text: '[${log.sourceLabel}] ',
418
+ style: TextStyle(color: _textSecondary),
419
+ ),
420
+ TextSpan(
421
+ text: log.message,
422
+ style: TextStyle(color: log.color),
423
+ ),
424
+ ],
425
+ ),
426
+ style: TextStyle(
427
+ fontSize: 12,
428
+ height: 1.5,
429
+ fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
430
+ ),
431
+ ),
432
+ );
433
+ }).toList(),
434
+ ),
435
+ ),
436
+ ),
437
+ ],
438
+ );
439
+ }
440
+ }
441
+
442
+ class SkillsPanel extends StatefulWidget {
443
+ const SkillsPanel({super.key, required this.controller});
444
+
445
+ final NeoAgentController controller;
446
+
447
+ @override
448
+ State<SkillsPanel> createState() => _SkillsPanelState();
449
+ }
450
+
451
+ class _SkillsPanelState extends State<SkillsPanel>
452
+ with SingleTickerProviderStateMixin {
453
+ late final TextEditingController _searchController;
454
+ late final TabController _tabController;
455
+ String _selectedCategory = 'all';
456
+
457
+ @override
458
+ void initState() {
459
+ super.initState();
460
+ _searchController = TextEditingController();
461
+ _tabController = TabController(length: 2, vsync: this);
462
+ }
463
+
464
+ @override
465
+ void dispose() {
466
+ _tabController.dispose();
467
+ _searchController.dispose();
468
+ super.dispose();
469
+ }
470
+
471
+ @override
472
+ Widget build(BuildContext context) {
473
+ final controller = widget.controller;
474
+ final query = _searchController.text.trim().toLowerCase();
475
+ final categories = <String>{
476
+ 'all',
477
+ ...controller.storeSkills.map((item) => item.category),
478
+ }.toList();
479
+ final filteredStore =
480
+ controller.storeSkills.where((item) {
481
+ final matchesQuery =
482
+ query.isEmpty ||
483
+ item.name.toLowerCase().contains(query) ||
484
+ item.description.toLowerCase().contains(query) ||
485
+ item.category.toLowerCase().contains(query);
486
+ final matchesCategory =
487
+ _selectedCategory == 'all' || item.category == _selectedCategory;
488
+ return matchesQuery && matchesCategory;
489
+ }).toList()..sort((a, b) {
490
+ if (a.installed != b.installed) {
491
+ return a.installed ? -1 : 1;
492
+ }
493
+ return a.name.toLowerCase().compareTo(b.name.toLowerCase());
494
+ });
495
+
496
+ return Padding(
497
+ padding: _pagePadding(context),
498
+ child: Column(
499
+ children: <Widget>[
500
+ _PageTitle(
501
+ title: 'Skills',
502
+ subtitle:
503
+ 'Manage installed skills and browse the store. Official integrations live in their own section.',
504
+ trailing: FilledButton.icon(
505
+ onPressed: () => _openCreateSkill(context),
506
+ icon: Icon(Icons.add),
507
+ label: Text('New Skill'),
508
+ ),
509
+ ),
510
+ const SizedBox(height: 12),
511
+ Container(
512
+ decoration: BoxDecoration(
513
+ color: _bgSecondary,
514
+ borderRadius: BorderRadius.circular(14),
515
+ border: Border.all(color: _border),
516
+ ),
517
+ child: TabBar(
518
+ controller: _tabController,
519
+ dividerColor: Colors.transparent,
520
+ indicatorSize: TabBarIndicatorSize.tab,
521
+ labelStyle: TextStyle(fontWeight: FontWeight.w700),
522
+ tabs: <Widget>[
523
+ Tab(text: 'Installed Skills (${controller.skills.length})'),
524
+ Tab(text: 'Store (${filteredStore.length})'),
525
+ ],
526
+ ),
527
+ ),
528
+ const SizedBox(height: 12),
529
+ Expanded(
530
+ child: TabBarView(
531
+ controller: _tabController,
532
+ children: <Widget>[
533
+ _buildInstalledTab(controller),
534
+ _buildStoreTab(controller, categories, filteredStore),
535
+ ],
536
+ ),
537
+ ),
538
+ ],
539
+ ),
540
+ );
541
+ }
542
+
543
+ Widget _buildInstalledTab(NeoAgentController controller) {
544
+ if (controller.skills.isEmpty) {
545
+ return Card(
546
+ child: Padding(
547
+ padding: const EdgeInsets.all(24),
548
+ child: Column(
549
+ mainAxisAlignment: MainAxisAlignment.center,
550
+ children: <Widget>[
551
+ Icon(
552
+ Icons.extension_off_outlined,
553
+ size: 34,
554
+ color: _textSecondary,
555
+ ),
556
+ SizedBox(height: 12),
557
+ Text(
558
+ 'No current skills yet. Install from Store or create a new one.',
559
+ textAlign: TextAlign.center,
560
+ style: TextStyle(color: _textSecondary),
561
+ ),
562
+ ],
563
+ ),
564
+ ),
565
+ );
566
+ }
567
+
568
+ return Card(
569
+ child: ListView.separated(
570
+ padding: const EdgeInsets.all(14),
571
+ itemCount: controller.skills.length,
572
+ separatorBuilder: (_, __) => const SizedBox(height: 10),
573
+ itemBuilder: (context, index) {
574
+ final skill = controller.skills[index];
575
+ return LayoutBuilder(
576
+ builder: (context, constraints) {
577
+ final compact = constraints.maxWidth < 760;
578
+ return Container(
579
+ padding: const EdgeInsets.all(14),
580
+ decoration: BoxDecoration(
581
+ color: _bgSecondary,
582
+ borderRadius: BorderRadius.circular(14),
583
+ border: Border.all(color: _border),
584
+ ),
585
+ child: compact
586
+ ? Column(
587
+ crossAxisAlignment: CrossAxisAlignment.start,
588
+ children: <Widget>[
589
+ Row(
590
+ children: <Widget>[
591
+ Expanded(
592
+ child: Text(
593
+ skill.name,
594
+ style: TextStyle(fontWeight: FontWeight.w700),
595
+ ),
596
+ ),
597
+ Switch(
598
+ value: skill.enabled,
599
+ onChanged: (value) => controller
600
+ .setSkillEnabled(skill.name, value),
601
+ ),
602
+ ],
603
+ ),
604
+ Text(
605
+ skill.description.ifEmpty('No description'),
606
+ style: TextStyle(color: _textSecondary),
607
+ ),
608
+ const SizedBox(height: 10),
609
+ Wrap(
610
+ spacing: 8,
611
+ runSpacing: 8,
612
+ children: <Widget>[
613
+ _MetaPill(
614
+ label: skill.category,
615
+ icon: Icons.folder_outlined,
616
+ ),
617
+ _MetaPill(
618
+ label: skill.source,
619
+ icon: Icons.source_outlined,
620
+ ),
621
+ if (skill.draft)
622
+ const _MetaPill(
623
+ label: 'Draft',
624
+ icon: Icons.edit_note_outlined,
625
+ ),
626
+ ],
627
+ ),
628
+ const SizedBox(height: 10),
629
+ Row(
630
+ children: <Widget>[
631
+ const Spacer(),
632
+ OutlinedButton(
633
+ onPressed: () =>
634
+ _openSkillEditor(context, skill.name),
635
+ child: Text('Open'),
636
+ ),
637
+ const SizedBox(width: 8),
638
+ TextButton.icon(
639
+ onPressed: () =>
640
+ _confirmDeleteSkill(context, skill.name),
641
+ icon: Icon(Icons.delete_outline),
642
+ style: TextButton.styleFrom(
643
+ foregroundColor: _danger,
644
+ ),
645
+ label: Text('Delete'),
646
+ ),
647
+ ],
648
+ ),
649
+ ],
650
+ )
651
+ : Row(
652
+ crossAxisAlignment: CrossAxisAlignment.start,
653
+ children: <Widget>[
654
+ Expanded(
655
+ child: Column(
656
+ crossAxisAlignment: CrossAxisAlignment.start,
657
+ children: <Widget>[
658
+ Text(
659
+ skill.name,
660
+ style: TextStyle(fontWeight: FontWeight.w700),
661
+ ),
662
+ const SizedBox(height: 6),
663
+ Text(
664
+ skill.description.ifEmpty('No description'),
665
+ style: TextStyle(color: _textSecondary),
666
+ ),
667
+ const SizedBox(height: 10),
668
+ Wrap(
669
+ spacing: 8,
670
+ runSpacing: 8,
671
+ children: <Widget>[
672
+ _MetaPill(
673
+ label: skill.category,
674
+ icon: Icons.folder_outlined,
675
+ ),
676
+ _MetaPill(
677
+ label: skill.source,
678
+ icon: Icons.source_outlined,
679
+ ),
680
+ if (skill.draft)
681
+ const _MetaPill(
682
+ label: 'Draft',
683
+ icon: Icons.edit_note_outlined,
684
+ ),
685
+ ],
686
+ ),
687
+ ],
688
+ ),
689
+ ),
690
+ const SizedBox(width: 10),
691
+ Column(
692
+ children: <Widget>[
693
+ Switch(
694
+ value: skill.enabled,
695
+ onChanged: (value) => controller
696
+ .setSkillEnabled(skill.name, value),
697
+ ),
698
+ OutlinedButton(
699
+ onPressed: () =>
700
+ _openSkillEditor(context, skill.name),
701
+ child: Text('Open'),
702
+ ),
703
+ const SizedBox(height: 6),
704
+ TextButton.icon(
705
+ onPressed: () =>
706
+ _confirmDeleteSkill(context, skill.name),
707
+ icon: Icon(Icons.delete_outline),
708
+ style: TextButton.styleFrom(
709
+ foregroundColor: _danger,
710
+ ),
711
+ label: Text('Delete'),
712
+ ),
713
+ ],
714
+ ),
715
+ ],
716
+ ),
717
+ );
718
+ },
719
+ );
720
+ },
721
+ ),
722
+ );
723
+ }
724
+
725
+ Widget _buildStoreTab(
726
+ NeoAgentController controller,
727
+ List<String> categories,
728
+ List<StoreSkillItem> filteredStore,
729
+ ) {
730
+ final featured = filteredStore.take(6).toList();
731
+ return Card(
732
+ child: ListView(
733
+ padding: const EdgeInsets.all(16),
734
+ children: <Widget>[
735
+ Container(
736
+ width: double.infinity,
737
+ padding: const EdgeInsets.all(16),
738
+ decoration: BoxDecoration(
739
+ gradient: LinearGradient(
740
+ colors: <Color>[_bgSecondary, _accentMuted],
741
+ begin: Alignment.topLeft,
742
+ end: Alignment.bottomRight,
743
+ ),
744
+ borderRadius: BorderRadius.circular(16),
745
+ border: Border.all(color: _borderLight),
746
+ ),
747
+ child: Column(
748
+ crossAxisAlignment: CrossAxisAlignment.start,
749
+ children: <Widget>[
750
+ Text(
751
+ 'Skill Store',
752
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
753
+ ),
754
+ SizedBox(height: 6),
755
+ Text(
756
+ 'Discover, install, and manage skills in a compact catalog.',
757
+ style: TextStyle(color: _textSecondary),
758
+ ),
759
+ ],
760
+ ),
761
+ ),
762
+ const SizedBox(height: 12),
763
+ TextField(
764
+ controller: _searchController,
765
+ onChanged: (_) => setState(() {}),
766
+ decoration: InputDecoration(
767
+ labelText: 'Search skills',
768
+ prefixIcon: Icon(Icons.search),
769
+ suffixIcon: _searchController.text.isEmpty
770
+ ? null
771
+ : IconButton(
772
+ onPressed: () {
773
+ _searchController.clear();
774
+ setState(() {});
775
+ },
776
+ icon: Icon(Icons.close),
777
+ ),
778
+ ),
779
+ ),
780
+ const SizedBox(height: 10),
781
+ SizedBox(
782
+ height: 38,
783
+ child: ListView.separated(
784
+ scrollDirection: Axis.horizontal,
785
+ itemCount: categories.length,
786
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
787
+ itemBuilder: (context, index) {
788
+ final category = categories[index];
789
+ final selected = category == _selectedCategory;
790
+ return FilterChip(
791
+ selected: selected,
792
+ label: Text(category == 'all' ? 'All' : category),
793
+ selectedColor: _accentMuted,
794
+ checkmarkColor: _accent,
795
+ backgroundColor: _bgSecondary,
796
+ side: BorderSide(color: _border),
797
+ onSelected: (_) =>
798
+ setState(() => _selectedCategory = category),
799
+ );
800
+ },
801
+ ),
802
+ ),
803
+ if (featured.isNotEmpty) ...<Widget>[
804
+ const SizedBox(height: 14),
805
+ const _SectionTitle('Featured'),
806
+ const SizedBox(height: 10),
807
+ SizedBox(
808
+ height: 170,
809
+ child: ListView.separated(
810
+ scrollDirection: Axis.horizontal,
811
+ itemCount: featured.length,
812
+ separatorBuilder: (_, __) => const SizedBox(width: 10),
813
+ itemBuilder: (context, index) {
814
+ final item = featured[index];
815
+ return Container(
816
+ width: 280,
817
+ padding: const EdgeInsets.all(14),
818
+ decoration: BoxDecoration(
819
+ color: _bgSecondary,
820
+ borderRadius: BorderRadius.circular(14),
821
+ border: Border.all(
822
+ color: item.installed ? _accentMuted : _border,
823
+ ),
824
+ ),
825
+ child: Column(
826
+ crossAxisAlignment: CrossAxisAlignment.start,
827
+ children: <Widget>[
828
+ Row(
829
+ children: <Widget>[
830
+ Text(item.icon, style: TextStyle(fontSize: 24)),
831
+ const SizedBox(width: 8),
832
+ Expanded(
833
+ child: Text(
834
+ item.name,
835
+ style: TextStyle(
836
+ fontWeight: FontWeight.w700,
837
+ fontSize: 16,
838
+ ),
839
+ ),
840
+ ),
841
+ item.installed
842
+ ? _StatusPill(
843
+ label: 'Installed',
844
+ color: _success,
845
+ )
846
+ : _StatusPill(label: 'Get', color: _info),
847
+ ],
848
+ ),
849
+ const SizedBox(height: 8),
850
+ Text(
851
+ item.description,
852
+ maxLines: 3,
853
+ overflow: TextOverflow.ellipsis,
854
+ style: TextStyle(color: _textSecondary, height: 1.35),
855
+ ),
856
+ const Spacer(),
857
+ Align(
858
+ alignment: Alignment.centerRight,
859
+ child: item.installed
860
+ ? OutlinedButton(
861
+ onPressed: () =>
862
+ controller.uninstallStoreSkill(item.id),
863
+ child: Text('Uninstall'),
864
+ )
865
+ : FilledButton(
866
+ onPressed: () =>
867
+ controller.installStoreSkill(item.id),
868
+ child: Text('Install'),
869
+ ),
870
+ ),
871
+ ],
872
+ ),
873
+ );
874
+ },
875
+ ),
876
+ ),
877
+ ],
878
+ const SizedBox(height: 14),
879
+ Row(
880
+ children: <Widget>[
881
+ const _SectionTitle('All Skills'),
882
+ const Spacer(),
883
+ Text(
884
+ '${filteredStore.length} results',
885
+ style: TextStyle(color: _textSecondary),
886
+ ),
887
+ ],
888
+ ),
889
+ const SizedBox(height: 10),
890
+ if (filteredStore.isEmpty)
891
+ Padding(
892
+ padding: EdgeInsets.symmetric(vertical: 24),
893
+ child: Text(
894
+ 'No store skills match the current filter.',
895
+ style: TextStyle(color: _textSecondary),
896
+ ),
897
+ )
898
+ else
899
+ ...filteredStore.map(
900
+ (item) => Padding(
901
+ padding: const EdgeInsets.only(bottom: 10),
902
+ child: Container(
903
+ padding: const EdgeInsets.all(12),
904
+ decoration: BoxDecoration(
905
+ color: _bgSecondary,
906
+ borderRadius: BorderRadius.circular(12),
907
+ border: Border.all(color: _border),
908
+ ),
909
+ child: LayoutBuilder(
910
+ builder: (context, constraints) {
911
+ final compact = constraints.maxWidth < 740;
912
+ if (compact) {
913
+ return Column(
914
+ crossAxisAlignment: CrossAxisAlignment.start,
915
+ children: <Widget>[
916
+ Row(
917
+ children: <Widget>[
918
+ Text(item.icon, style: TextStyle(fontSize: 22)),
919
+ const SizedBox(width: 10),
920
+ Expanded(
921
+ child: Text(
922
+ item.name,
923
+ style: TextStyle(
924
+ fontWeight: FontWeight.w700,
925
+ ),
926
+ ),
927
+ ),
928
+ _StatusPill(
929
+ label: item.installed ? 'Installed' : 'Get',
930
+ color: item.installed ? _success : _info,
931
+ ),
932
+ ],
933
+ ),
934
+ const SizedBox(height: 8),
935
+ Text(
936
+ item.description,
937
+ style: TextStyle(color: _textSecondary),
938
+ ),
939
+ const SizedBox(height: 8),
940
+ Row(
941
+ children: <Widget>[
942
+ _MetaPill(
943
+ label: item.category,
944
+ icon: Icons.grid_view_rounded,
945
+ ),
946
+ const Spacer(),
947
+ item.installed
948
+ ? OutlinedButton(
949
+ onPressed: () => controller
950
+ .uninstallStoreSkill(item.id),
951
+ child: Text('Uninstall'),
952
+ )
953
+ : FilledButton(
954
+ onPressed: () => controller
955
+ .installStoreSkill(item.id),
956
+ child: Text('Install'),
957
+ ),
958
+ ],
959
+ ),
960
+ ],
961
+ );
962
+ }
963
+ return Row(
964
+ children: <Widget>[
965
+ Text(item.icon, style: TextStyle(fontSize: 24)),
966
+ const SizedBox(width: 12),
967
+ Expanded(
968
+ child: Column(
969
+ crossAxisAlignment: CrossAxisAlignment.start,
970
+ children: <Widget>[
971
+ Text(
972
+ item.name,
973
+ style: TextStyle(
974
+ fontWeight: FontWeight.w700,
975
+ fontSize: 16,
976
+ ),
977
+ ),
978
+ const SizedBox(height: 4),
979
+ Text(
980
+ item.description,
981
+ maxLines: 2,
982
+ overflow: TextOverflow.ellipsis,
983
+ style: TextStyle(
984
+ color: _textSecondary,
985
+ height: 1.35,
986
+ ),
987
+ ),
988
+ const SizedBox(height: 8),
989
+ _MetaPill(
990
+ label: item.category,
991
+ icon: Icons.grid_view_rounded,
992
+ ),
993
+ ],
994
+ ),
995
+ ),
996
+ const SizedBox(width: 10),
997
+ item.installed
998
+ ? OutlinedButton(
999
+ onPressed: () =>
1000
+ controller.uninstallStoreSkill(item.id),
1001
+ child: Text('Uninstall'),
1002
+ )
1003
+ : FilledButton(
1004
+ onPressed: () =>
1005
+ controller.installStoreSkill(item.id),
1006
+ child: Text('Install'),
1007
+ ),
1008
+ ],
1009
+ );
1010
+ },
1011
+ ),
1012
+ ),
1013
+ ),
1014
+ ),
1015
+ ],
1016
+ ),
1017
+ );
1018
+ }
1019
+
1020
+ Future<void> _openSkillEditor(BuildContext context, String name) async {
1021
+ final document = await widget.controller.fetchSkillDocument(name);
1022
+ final contentController = TextEditingController(text: document.content);
1023
+ if (!context.mounted) {
1024
+ return;
1025
+ }
1026
+ await showDialog<void>(
1027
+ context: context,
1028
+ builder: (context) {
1029
+ return AlertDialog(
1030
+ backgroundColor: _bgCard,
1031
+ title: Text(name),
1032
+ content: SizedBox(
1033
+ width: 720,
1034
+ child: TextField(
1035
+ controller: contentController,
1036
+ minLines: 16,
1037
+ maxLines: 24,
1038
+ decoration: const InputDecoration(labelText: 'Skill Content'),
1039
+ ),
1040
+ ),
1041
+ actions: <Widget>[
1042
+ TextButton(
1043
+ onPressed: () => Navigator.of(context).pop(),
1044
+ child: Text('Cancel'),
1045
+ ),
1046
+ FilledButton(
1047
+ onPressed: () async {
1048
+ await widget.controller.saveSkillContent(
1049
+ name: name,
1050
+ content: contentController.text,
1051
+ );
1052
+ if (context.mounted) {
1053
+ Navigator.of(context).pop();
1054
+ }
1055
+ },
1056
+ child: Text('Save'),
1057
+ ),
1058
+ ],
1059
+ );
1060
+ },
1061
+ );
1062
+ }
1063
+
1064
+ Future<void> _openCreateSkill(BuildContext context) async {
1065
+ final nameController = TextEditingController();
1066
+ final contentController = TextEditingController(
1067
+ text: '''---
1068
+ name: New Skill
1069
+ description: Describe what this skill does
1070
+ ---
1071
+ Write the instructions for this skill here.
1072
+ ''',
1073
+ );
1074
+
1075
+ await showDialog<void>(
1076
+ context: context,
1077
+ builder: (context) {
1078
+ return AlertDialog(
1079
+ backgroundColor: _bgCard,
1080
+ title: Text('New Skill'),
1081
+ content: SizedBox(
1082
+ width: 720,
1083
+ child: SingleChildScrollView(
1084
+ child: Column(
1085
+ mainAxisSize: MainAxisSize.min,
1086
+ children: <Widget>[
1087
+ TextField(
1088
+ controller: nameController,
1089
+ decoration: const InputDecoration(labelText: 'Filename'),
1090
+ ),
1091
+ const SizedBox(height: 12),
1092
+ TextField(
1093
+ controller: contentController,
1094
+ minLines: 16,
1095
+ maxLines: 24,
1096
+ decoration: const InputDecoration(labelText: 'Content'),
1097
+ ),
1098
+ ],
1099
+ ),
1100
+ ),
1101
+ ),
1102
+ actions: <Widget>[
1103
+ TextButton(
1104
+ onPressed: () => Navigator.of(context).pop(),
1105
+ child: Text('Cancel'),
1106
+ ),
1107
+ FilledButton(
1108
+ onPressed: () async {
1109
+ await widget.controller.createSkill(
1110
+ filename: nameController.text.trim(),
1111
+ content: contentController.text,
1112
+ );
1113
+ if (context.mounted) {
1114
+ Navigator.of(context).pop();
1115
+ }
1116
+ },
1117
+ child: Text('Create'),
1118
+ ),
1119
+ ],
1120
+ );
1121
+ },
1122
+ );
1123
+ }
1124
+
1125
+ Future<void> _confirmDeleteSkill(BuildContext context, String name) async {
1126
+ final shouldDelete = await showDialog<bool>(
1127
+ context: context,
1128
+ builder: (context) {
1129
+ return AlertDialog(
1130
+ backgroundColor: _bgCard,
1131
+ title: Text('Delete skill?'),
1132
+ content: Text('"$name" will be removed permanently.'),
1133
+ actions: <Widget>[
1134
+ TextButton(
1135
+ onPressed: () => Navigator.of(context).pop(false),
1136
+ child: Text('Cancel'),
1137
+ ),
1138
+ FilledButton(
1139
+ style: FilledButton.styleFrom(backgroundColor: _danger),
1140
+ onPressed: () => Navigator.of(context).pop(true),
1141
+ child: Text('Delete'),
1142
+ ),
1143
+ ],
1144
+ );
1145
+ },
1146
+ );
1147
+
1148
+ if (shouldDelete != true) {
1149
+ return;
1150
+ }
1151
+
1152
+ try {
1153
+ await widget.controller.deleteSkill(name);
1154
+ if (!context.mounted) {
1155
+ return;
1156
+ }
1157
+ ScaffoldMessenger.of(
1158
+ context,
1159
+ ).showSnackBar(SnackBar(content: Text('Deleted "$name".')));
1160
+ } catch (error) {
1161
+ if (!context.mounted) {
1162
+ return;
1163
+ }
1164
+ ScaffoldMessenger.of(context).showSnackBar(
1165
+ SnackBar(content: Text('Failed to delete "$name": $error')),
1166
+ );
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ class MemoryPanel extends StatefulWidget {
1172
+ const MemoryPanel({super.key, required this.controller});
1173
+
1174
+ final NeoAgentController controller;
1175
+
1176
+ @override
1177
+ State<MemoryPanel> createState() => _MemoryPanelState();
1178
+ }
1179
+
1180
+ class _MemoryPanelState extends State<MemoryPanel> {
1181
+ late final TextEditingController _searchController;
1182
+ final Set<String> _selectedMemoryIds = <String>{};
1183
+ bool _bulkActionInFlight = false;
1184
+
1185
+ @override
1186
+ void initState() {
1187
+ super.initState();
1188
+ _searchController = TextEditingController();
1189
+ }
1190
+
1191
+ @override
1192
+ void dispose() {
1193
+ _searchController.dispose();
1194
+ super.dispose();
1195
+ }
1196
+
1197
+ List<MemoryItem> get _visibleMemories {
1198
+ final controller = widget.controller;
1199
+ return controller.memoryRecallResults.isNotEmpty
1200
+ ? controller.memoryRecallResults
1201
+ : controller.memories;
1202
+ }
1203
+
1204
+ List<String> get _selectedVisibleMemoryIds {
1205
+ final visibleIds = _visibleMemories.map((memory) => memory.id).toSet();
1206
+ return _selectedMemoryIds
1207
+ .where(visibleIds.contains)
1208
+ .toList(growable: false);
1209
+ }
1210
+
1211
+ void _toggleMemorySelection(String id, bool selected) {
1212
+ setState(() {
1213
+ if (selected) {
1214
+ _selectedMemoryIds.add(id);
1215
+ } else {
1216
+ _selectedMemoryIds.remove(id);
1217
+ }
1218
+ });
1219
+ }
1220
+
1221
+ void _clearMemorySelection() {
1222
+ if (_selectedMemoryIds.isEmpty) {
1223
+ return;
1224
+ }
1225
+ setState(() {
1226
+ _selectedMemoryIds.clear();
1227
+ });
1228
+ }
1229
+
1230
+ void _selectAllVisibleMemories(List<MemoryItem> memories) {
1231
+ if (memories.isEmpty) {
1232
+ return;
1233
+ }
1234
+ setState(() {
1235
+ _selectedMemoryIds.addAll(memories.map((memory) => memory.id));
1236
+ });
1237
+ }
1238
+
1239
+ Future<void> _runMemorySearch(NeoAgentController controller) async {
1240
+ _clearMemorySelection();
1241
+ final query = _searchController.text.trim();
1242
+ if (query.isEmpty) {
1243
+ controller.clearMemorySearch();
1244
+ } else {
1245
+ await controller.searchMemories(query);
1246
+ }
1247
+ }
1248
+
1249
+ void _resetMemorySearch(NeoAgentController controller) {
1250
+ _searchController.clear();
1251
+ _clearMemorySelection();
1252
+ controller.clearMemorySearch();
1253
+ }
1254
+
1255
+ Future<void> _deleteSingleMemory(
1256
+ NeoAgentController controller,
1257
+ String id,
1258
+ ) async {
1259
+ await controller.deleteMemory(id);
1260
+ if (!mounted) {
1261
+ return;
1262
+ }
1263
+ setState(() {
1264
+ _selectedMemoryIds.remove(id);
1265
+ });
1266
+ }
1267
+
1268
+ Future<void> _runBulkMemoryAction({
1269
+ required String title,
1270
+ required String message,
1271
+ required String confirmLabel,
1272
+ required Future<void> Function(List<String> ids) onConfirm,
1273
+ }) async {
1274
+ final ids = _selectedVisibleMemoryIds;
1275
+ if (ids.isEmpty || _bulkActionInFlight) {
1276
+ return;
1277
+ }
1278
+ await _confirmDelete(
1279
+ context,
1280
+ title: title,
1281
+ message: message,
1282
+ confirmLabel: confirmLabel,
1283
+ onConfirm: () async {
1284
+ setState(() {
1285
+ _bulkActionInFlight = true;
1286
+ });
1287
+ try {
1288
+ await onConfirm(ids);
1289
+ if (!mounted) {
1290
+ return;
1291
+ }
1292
+ setState(() {
1293
+ _selectedMemoryIds.removeAll(ids);
1294
+ });
1295
+ } finally {
1296
+ if (mounted) {
1297
+ setState(() {
1298
+ _bulkActionInFlight = false;
1299
+ });
1300
+ }
1301
+ }
1302
+ },
1303
+ );
1304
+ }
1305
+
1306
+ @override
1307
+ Widget build(BuildContext context) {
1308
+ final controller = widget.controller;
1309
+ final memoriesToShow = _visibleMemories;
1310
+ final selectedMemoryIds = _selectedVisibleMemoryIds.toSet();
1311
+ final selectedCount = selectedMemoryIds.length;
1312
+ final allVisibleSelected =
1313
+ memoriesToShow.isNotEmpty &&
1314
+ memoriesToShow.every((memory) => selectedMemoryIds.contains(memory.id));
1315
+ final showingSearchResults = controller.memoryRecallResults.isNotEmpty;
1316
+
1317
+ return ListView(
1318
+ padding: _pagePadding(context),
1319
+ children: <Widget>[
1320
+ _PageTitle(
1321
+ title: 'Memory',
1322
+ subtitle:
1323
+ 'Core memory, thread context, long-term recall, daily logs, and behavior notes.',
1324
+ trailing: Wrap(
1325
+ spacing: 10,
1326
+ runSpacing: 10,
1327
+ children: <Widget>[
1328
+ OutlinedButton.icon(
1329
+ onPressed: () => _openBehaviorNotesEditor(context, controller),
1330
+ icon: Icon(Icons.edit_outlined),
1331
+ label: Text('Behavior Notes'),
1332
+ ),
1333
+ FilledButton.icon(
1334
+ onPressed: () => _openMemoryCreator(context, controller),
1335
+ icon: Icon(Icons.add),
1336
+ label: Text('Add Memory'),
1337
+ ),
1338
+ ],
1339
+ ),
1340
+ ),
1341
+ Row(
1342
+ children: <Widget>[
1343
+ Expanded(
1344
+ child: _OverviewCard(
1345
+ title: 'Behavior Notes',
1346
+ value: '${controller.memoryOverview.behaviorNotesLength} chars',
1347
+ helper: 'Durable assistant style guidance',
1348
+ ),
1349
+ ),
1350
+ const SizedBox(width: 12),
1351
+ Expanded(
1352
+ child: _OverviewCard(
1353
+ title: 'Core Memory',
1354
+ value: '${controller.memoryOverview.coreCount}',
1355
+ helper: 'Pinned key/value entries',
1356
+ ),
1357
+ ),
1358
+ const SizedBox(width: 12),
1359
+ Expanded(
1360
+ child: _OverviewCard(
1361
+ title: 'Daily Logs',
1362
+ value: '${controller.memoryOverview.dailyLogCount}',
1363
+ helper: 'Recent captured log files',
1364
+ ),
1365
+ ),
1366
+ const SizedBox(width: 12),
1367
+ Expanded(
1368
+ child: _OverviewCard(
1369
+ title: 'API Keys',
1370
+ value: '${controller.memoryOverview.apiKeyCount}',
1371
+ helper: 'Masked agent-managed credentials',
1372
+ ),
1373
+ ),
1374
+ ],
1375
+ ),
1376
+ const SizedBox(height: 16),
1377
+ Card(
1378
+ child: Padding(
1379
+ padding: const EdgeInsets.all(18),
1380
+ child: Column(
1381
+ crossAxisAlignment: CrossAxisAlignment.start,
1382
+ children: <Widget>[
1383
+ const _SectionTitle('Recall Search'),
1384
+ const SizedBox(height: 12),
1385
+ Row(
1386
+ children: <Widget>[
1387
+ Expanded(
1388
+ child: TextField(
1389
+ controller: _searchController,
1390
+ decoration: const InputDecoration(
1391
+ labelText: 'Search memory',
1392
+ ),
1393
+ onSubmitted: (_) => _runMemorySearch(controller),
1394
+ ),
1395
+ ),
1396
+ const SizedBox(width: 10),
1397
+ FilledButton(
1398
+ onPressed: () => _runMemorySearch(controller),
1399
+ child: Text('Search'),
1400
+ ),
1401
+ const SizedBox(width: 10),
1402
+ OutlinedButton(
1403
+ onPressed: () => _resetMemorySearch(controller),
1404
+ child: Text('Reset'),
1405
+ ),
1406
+ ],
1407
+ ),
1408
+ ],
1409
+ ),
1410
+ ),
1411
+ ),
1412
+ const SizedBox(height: 16),
1413
+ Card(
1414
+ child: Padding(
1415
+ padding: const EdgeInsets.all(18),
1416
+ child: Column(
1417
+ crossAxisAlignment: CrossAxisAlignment.start,
1418
+ children: <Widget>[
1419
+ Row(
1420
+ children: <Widget>[
1421
+ Expanded(child: _SectionTitle('Core Memory')),
1422
+ TextButton.icon(
1423
+ onPressed: () =>
1424
+ _openCoreMemoryEditor(context, controller),
1425
+ icon: Icon(Icons.add),
1426
+ label: Text('Add Entry'),
1427
+ ),
1428
+ ],
1429
+ ),
1430
+ const SizedBox(height: 10),
1431
+ if (controller.memoryOverview.coreEntries.isEmpty)
1432
+ Text(
1433
+ 'No core memory entries yet.',
1434
+ style: TextStyle(color: _textSecondary),
1435
+ )
1436
+ else
1437
+ ...controller.memoryOverview.coreEntries.entries.map((entry) {
1438
+ return Container(
1439
+ width: double.infinity,
1440
+ margin: const EdgeInsets.only(bottom: 10),
1441
+ padding: const EdgeInsets.all(12),
1442
+ decoration: BoxDecoration(
1443
+ color: _bgSecondary,
1444
+ borderRadius: BorderRadius.circular(12),
1445
+ border: Border.all(color: _border),
1446
+ ),
1447
+ child: Row(
1448
+ crossAxisAlignment: CrossAxisAlignment.start,
1449
+ children: <Widget>[
1450
+ Expanded(
1451
+ child: Column(
1452
+ crossAxisAlignment: CrossAxisAlignment.start,
1453
+ children: <Widget>[
1454
+ Text(
1455
+ entry.key,
1456
+ style: TextStyle(fontWeight: FontWeight.w700),
1457
+ ),
1458
+ const SizedBox(height: 6),
1459
+ Text(entry.value.toString()),
1460
+ ],
1461
+ ),
1462
+ ),
1463
+ IconButton(
1464
+ onPressed: () => _openCoreMemoryEditor(
1465
+ context,
1466
+ controller,
1467
+ keyValue: entry,
1468
+ ),
1469
+ icon: Icon(Icons.edit_outlined),
1470
+ ),
1471
+ IconButton(
1472
+ onPressed: () => _confirmDelete(
1473
+ context,
1474
+ title: 'Delete core memory entry?',
1475
+ message:
1476
+ 'Remove "${entry.key}" from core memory.',
1477
+ onConfirm: () =>
1478
+ controller.deleteCoreMemory(entry.key),
1479
+ ),
1480
+ icon: Icon(Icons.delete_outline),
1481
+ ),
1482
+ ],
1483
+ ),
1484
+ );
1485
+ }),
1486
+ ],
1487
+ ),
1488
+ ),
1489
+ ),
1490
+ const SizedBox(height: 16),
1491
+ Card(
1492
+ child: Padding(
1493
+ padding: const EdgeInsets.all(18),
1494
+ child: Column(
1495
+ crossAxisAlignment: CrossAxisAlignment.start,
1496
+ children: <Widget>[
1497
+ const _SectionTitle('Memories'),
1498
+ const SizedBox(height: 6),
1499
+ Text(
1500
+ showingSearchResults
1501
+ ? 'Showing search results. Select memories to archive or delete them together.'
1502
+ : 'Select one or more memories to archive or delete them together.',
1503
+ style: TextStyle(color: _textSecondary),
1504
+ ),
1505
+ const SizedBox(height: 10),
1506
+ if (memoriesToShow.isNotEmpty)
1507
+ Wrap(
1508
+ spacing: 10,
1509
+ runSpacing: 10,
1510
+ crossAxisAlignment: WrapCrossAlignment.center,
1511
+ children: <Widget>[
1512
+ OutlinedButton.icon(
1513
+ onPressed: allVisibleSelected || _bulkActionInFlight
1514
+ ? null
1515
+ : () => _selectAllVisibleMemories(memoriesToShow),
1516
+ icon: Icon(Icons.done_all_outlined),
1517
+ label: Text(
1518
+ allVisibleSelected ? 'All Selected' : 'Select All',
1519
+ ),
1520
+ ),
1521
+ OutlinedButton.icon(
1522
+ onPressed: selectedCount == 0 || _bulkActionInFlight
1523
+ ? null
1524
+ : _clearMemorySelection,
1525
+ icon: Icon(Icons.deselect_outlined),
1526
+ label: Text('Clear Selection'),
1527
+ ),
1528
+ if (selectedCount > 0)
1529
+ FilledButton.icon(
1530
+ onPressed: _bulkActionInFlight
1531
+ ? null
1532
+ : () => _runBulkMemoryAction(
1533
+ title: 'Archive selected memories?',
1534
+ message:
1535
+ 'Archive $selectedCount selected ${selectedCount == 1 ? 'memory' : 'memories'}? Archived memories are removed from the main list.',
1536
+ confirmLabel: 'Archive',
1537
+ onConfirm: controller.archiveMemories,
1538
+ ),
1539
+ icon: Icon(Icons.archive_outlined),
1540
+ label: Text('Archive ($selectedCount)'),
1541
+ ),
1542
+ if (selectedCount > 0)
1543
+ OutlinedButton.icon(
1544
+ onPressed: _bulkActionInFlight
1545
+ ? null
1546
+ : () => _runBulkMemoryAction(
1547
+ title: 'Delete selected memories?',
1548
+ message:
1549
+ 'Delete $selectedCount selected ${selectedCount == 1 ? 'memory' : 'memories'} permanently?',
1550
+ confirmLabel: 'Delete',
1551
+ onConfirm: controller.deleteMemories,
1552
+ ),
1553
+ icon: Icon(Icons.delete_sweep_outlined),
1554
+ label: Text('Delete ($selectedCount)'),
1555
+ ),
1556
+ ],
1557
+ ),
1558
+ if (selectedCount > 0) ...<Widget>[
1559
+ const SizedBox(height: 10),
1560
+ Text(
1561
+ '$selectedCount selected',
1562
+ style: TextStyle(
1563
+ color: _textSecondary,
1564
+ fontWeight: FontWeight.w600,
1565
+ ),
1566
+ ),
1567
+ ],
1568
+ if (memoriesToShow.isNotEmpty) const SizedBox(height: 10),
1569
+ if (memoriesToShow.isEmpty)
1570
+ Text(
1571
+ 'No memory entries found.',
1572
+ style: TextStyle(color: _textSecondary),
1573
+ )
1574
+ else
1575
+ ...memoriesToShow.map((memory) {
1576
+ final isSelected = selectedMemoryIds.contains(memory.id);
1577
+ return Container(
1578
+ width: double.infinity,
1579
+ margin: const EdgeInsets.only(bottom: 10),
1580
+ decoration: BoxDecoration(
1581
+ color: isSelected ? _accentMuted : _bgSecondary,
1582
+ borderRadius: BorderRadius.circular(12),
1583
+ border: Border.all(
1584
+ color: isSelected ? _accent : _border,
1585
+ ),
1586
+ ),
1587
+ child: Material(
1588
+ color: Colors.transparent,
1589
+ child: InkWell(
1590
+ borderRadius: BorderRadius.circular(12),
1591
+ onTap: () =>
1592
+ _toggleMemorySelection(memory.id, !isSelected),
1593
+ child: Padding(
1594
+ padding: const EdgeInsets.all(12),
1595
+ child: Row(
1596
+ crossAxisAlignment: CrossAxisAlignment.start,
1597
+ children: <Widget>[
1598
+ Checkbox(
1599
+ value: isSelected,
1600
+ onChanged: (value) => _toggleMemorySelection(
1601
+ memory.id,
1602
+ value ?? false,
1603
+ ),
1604
+ ),
1605
+ const SizedBox(width: 8),
1606
+ Expanded(
1607
+ child: Column(
1608
+ crossAxisAlignment:
1609
+ CrossAxisAlignment.start,
1610
+ children: <Widget>[
1611
+ Row(
1612
+ crossAxisAlignment:
1613
+ CrossAxisAlignment.start,
1614
+ children: <Widget>[
1615
+ Expanded(
1616
+ child: Wrap(
1617
+ spacing: 10,
1618
+ runSpacing: 10,
1619
+ children: <Widget>[
1620
+ _MetaPill(
1621
+ label: memory.category,
1622
+ icon: Icons.label_outline,
1623
+ ),
1624
+ _MetaPill(
1625
+ label:
1626
+ 'Importance ${memory.importance}',
1627
+ icon: Icons
1628
+ .priority_high_outlined,
1629
+ ),
1630
+ ],
1631
+ ),
1632
+ ),
1633
+ IconButton(
1634
+ onPressed: _bulkActionInFlight
1635
+ ? null
1636
+ : () => _confirmDelete(
1637
+ context,
1638
+ title: 'Delete memory?',
1639
+ message:
1640
+ 'This memory entry will be removed permanently.',
1641
+ onConfirm: () =>
1642
+ _deleteSingleMemory(
1643
+ controller,
1644
+ memory.id,
1645
+ ),
1646
+ ),
1647
+ icon: Icon(Icons.delete_outline),
1648
+ ),
1649
+ ],
1650
+ ),
1651
+ const SizedBox(height: 10),
1652
+ Text(memory.content),
1653
+ const SizedBox(height: 8),
1654
+ Text(
1655
+ memory.createdAtLabel,
1656
+ style: TextStyle(
1657
+ fontSize: 12,
1658
+ color: _textSecondary,
1659
+ ),
1660
+ ),
1661
+ ],
1662
+ ),
1663
+ ),
1664
+ ],
1665
+ ),
1666
+ ),
1667
+ ),
1668
+ ),
1669
+ );
1670
+ }),
1671
+ ],
1672
+ ),
1673
+ ),
1674
+ ),
1675
+ const SizedBox(height: 16),
1676
+ Card(
1677
+ child: Padding(
1678
+ padding: const EdgeInsets.all(18),
1679
+ child: Column(
1680
+ crossAxisAlignment: CrossAxisAlignment.start,
1681
+ children: <Widget>[
1682
+ const _SectionTitle('Recent Conversations'),
1683
+ const SizedBox(height: 10),
1684
+ if (controller.memoryConversations.isEmpty)
1685
+ Text(
1686
+ 'No recent conversations found.',
1687
+ style: TextStyle(color: _textSecondary),
1688
+ )
1689
+ else
1690
+ ...controller.memoryConversations.map(
1691
+ (conversation) => Padding(
1692
+ padding: const EdgeInsets.only(bottom: 10),
1693
+ child: Container(
1694
+ width: double.infinity,
1695
+ padding: const EdgeInsets.all(12),
1696
+ decoration: BoxDecoration(
1697
+ color: _bgSecondary,
1698
+ borderRadius: BorderRadius.circular(12),
1699
+ border: Border.all(color: _border),
1700
+ ),
1701
+ child: Column(
1702
+ crossAxisAlignment: CrossAxisAlignment.start,
1703
+ children: <Widget>[
1704
+ Text(
1705
+ conversation.title,
1706
+ style: TextStyle(fontWeight: FontWeight.w700),
1707
+ ),
1708
+ const SizedBox(height: 8),
1709
+ Text(
1710
+ conversation.preview,
1711
+ style: TextStyle(color: _textSecondary),
1712
+ ),
1713
+ ],
1714
+ ),
1715
+ ),
1716
+ ),
1717
+ ),
1718
+ ],
1719
+ ),
1720
+ ),
1721
+ ),
1722
+ ],
1723
+ );
1724
+ }
1725
+
1726
+ Future<void> _openMemoryCreator(
1727
+ BuildContext context,
1728
+ NeoAgentController controller,
1729
+ ) async {
1730
+ final contentController = TextEditingController();
1731
+ final importanceController = TextEditingController(text: '5');
1732
+ String category = 'episodic';
1733
+
1734
+ await showDialog<void>(
1735
+ context: context,
1736
+ builder: (context) {
1737
+ return AlertDialog(
1738
+ backgroundColor: _bgCard,
1739
+ title: Text('Add Memory'),
1740
+ content: SizedBox(
1741
+ width: 620,
1742
+ child: Column(
1743
+ mainAxisSize: MainAxisSize.min,
1744
+ children: <Widget>[
1745
+ DropdownButtonFormField<String>(
1746
+ initialValue: category,
1747
+ items: const <DropdownMenuItem<String>>[
1748
+ DropdownMenuItem(
1749
+ value: 'episodic',
1750
+ child: Text('episodic'),
1751
+ ),
1752
+ DropdownMenuItem(
1753
+ value: 'user_fact',
1754
+ child: Text('user_fact'),
1755
+ ),
1756
+ DropdownMenuItem(
1757
+ value: 'preference',
1758
+ child: Text('preference'),
1759
+ ),
1760
+ DropdownMenuItem(
1761
+ value: 'personality',
1762
+ child: Text('personality'),
1763
+ ),
1764
+ ],
1765
+ decoration: const InputDecoration(labelText: 'Category'),
1766
+ onChanged: (value) {
1767
+ if (value != null) {
1768
+ category = value;
1769
+ }
1770
+ },
1771
+ ),
1772
+ const SizedBox(height: 12),
1773
+ TextField(
1774
+ controller: importanceController,
1775
+ decoration: const InputDecoration(labelText: 'Importance'),
1776
+ ),
1777
+ const SizedBox(height: 12),
1778
+ TextField(
1779
+ controller: contentController,
1780
+ minLines: 6,
1781
+ maxLines: 10,
1782
+ decoration: const InputDecoration(labelText: 'Content'),
1783
+ ),
1784
+ ],
1785
+ ),
1786
+ ),
1787
+ actions: <Widget>[
1788
+ TextButton(
1789
+ onPressed: () => Navigator.of(context).pop(),
1790
+ child: Text('Cancel'),
1791
+ ),
1792
+ FilledButton(
1793
+ onPressed: () async {
1794
+ await controller.createMemory(
1795
+ content: contentController.text.trim(),
1796
+ category: category,
1797
+ importance:
1798
+ int.tryParse(importanceController.text.trim()) ?? 5,
1799
+ );
1800
+ if (context.mounted) {
1801
+ Navigator.of(context).pop();
1802
+ }
1803
+ },
1804
+ child: Text('Save'),
1805
+ ),
1806
+ ],
1807
+ );
1808
+ },
1809
+ );
1810
+ }
1811
+
1812
+ Future<void> _openBehaviorNotesEditor(
1813
+ BuildContext context,
1814
+ NeoAgentController controller,
1815
+ ) async {
1816
+ final contentController = TextEditingController(
1817
+ text: controller.memoryOverview.assistantBehaviorNotes,
1818
+ );
1819
+ await showDialog<void>(
1820
+ context: context,
1821
+ builder: (context) {
1822
+ return AlertDialog(
1823
+ backgroundColor: _bgCard,
1824
+ title: Text('Edit Assistant Behavior Notes'),
1825
+ content: SizedBox(
1826
+ width: 720,
1827
+ child: TextField(
1828
+ controller: contentController,
1829
+ minLines: 16,
1830
+ maxLines: 24,
1831
+ decoration: const InputDecoration(
1832
+ labelText: 'assistant_behavior_notes',
1833
+ ),
1834
+ ),
1835
+ ),
1836
+ actions: <Widget>[
1837
+ TextButton(
1838
+ onPressed: () => Navigator.of(context).pop(),
1839
+ child: Text('Cancel'),
1840
+ ),
1841
+ FilledButton(
1842
+ onPressed: () async {
1843
+ await controller.updateAssistantBehaviorNotes(
1844
+ contentController.text,
1845
+ );
1846
+ if (context.mounted) {
1847
+ Navigator.of(context).pop();
1848
+ }
1849
+ },
1850
+ child: Text('Save'),
1851
+ ),
1852
+ ],
1853
+ );
1854
+ },
1855
+ );
1856
+ }
1857
+
1858
+ Future<void> _openCoreMemoryEditor(
1859
+ BuildContext context,
1860
+ NeoAgentController controller, {
1861
+ MapEntry<String, dynamic>? keyValue,
1862
+ }) async {
1863
+ final keyController = TextEditingController(text: keyValue?.key ?? '');
1864
+ final valueController = TextEditingController(
1865
+ text: keyValue?.value?.toString() ?? '',
1866
+ );
1867
+ await showDialog<void>(
1868
+ context: context,
1869
+ builder: (context) {
1870
+ return AlertDialog(
1871
+ backgroundColor: _bgCard,
1872
+ title: Text(
1873
+ keyValue == null
1874
+ ? 'Add Core Memory Entry'
1875
+ : 'Edit Core Memory Entry',
1876
+ ),
1877
+ content: SizedBox(
1878
+ width: 620,
1879
+ child: Column(
1880
+ mainAxisSize: MainAxisSize.min,
1881
+ children: <Widget>[
1882
+ TextField(
1883
+ controller: keyController,
1884
+ decoration: const InputDecoration(labelText: 'Key'),
1885
+ ),
1886
+ const SizedBox(height: 12),
1887
+ TextField(
1888
+ controller: valueController,
1889
+ minLines: 3,
1890
+ maxLines: 8,
1891
+ decoration: const InputDecoration(labelText: 'Value'),
1892
+ ),
1893
+ ],
1894
+ ),
1895
+ ),
1896
+ actions: <Widget>[
1897
+ TextButton(
1898
+ onPressed: () => Navigator.of(context).pop(),
1899
+ child: Text('Cancel'),
1900
+ ),
1901
+ FilledButton(
1902
+ onPressed: () async {
1903
+ await controller.updateCoreMemory(
1904
+ keyController.text.trim(),
1905
+ valueController.text.trim(),
1906
+ );
1907
+ if (context.mounted) {
1908
+ Navigator.of(context).pop();
1909
+ }
1910
+ },
1911
+ child: Text('Save'),
1912
+ ),
1913
+ ],
1914
+ );
1915
+ },
1916
+ );
1917
+ }
1918
+ }
1919
+
1920
+ class WidgetsPanel extends StatelessWidget {
1921
+ const WidgetsPanel({super.key, required this.controller});
1922
+
1923
+ final NeoAgentController controller;
1924
+
1925
+ @override
1926
+ Widget build(BuildContext context) {
1927
+ return ListView(
1928
+ padding: _pagePadding(context),
1929
+ children: <Widget>[
1930
+ _PageTitle(
1931
+ title: 'Widgets',
1932
+ subtitle:
1933
+ 'Beautiful, glanceable AI widgets that stay in sync across the app, launcher, and Android home screen.',
1934
+ trailing: Wrap(
1935
+ spacing: 10,
1936
+ runSpacing: 10,
1937
+ children: <Widget>[
1938
+ OutlinedButton.icon(
1939
+ onPressed: controller.refreshWidgets,
1940
+ icon: Icon(Icons.refresh_rounded),
1941
+ label: Text('Refresh'),
1942
+ ),
1943
+ FilledButton.icon(
1944
+ onPressed: controller.openWidgetCreateFlow,
1945
+ icon: Icon(Icons.auto_awesome_outlined),
1946
+ label: Text('Create With AI'),
1947
+ ),
1948
+ ],
1949
+ ),
1950
+ ),
1951
+ if (controller.widgets.isEmpty)
1952
+ const _EmptyCard(
1953
+ title: 'No AI widgets yet',
1954
+ subtitle:
1955
+ 'Create a widget through the agent and it will appear here, in launcher mode, and in Android home widgets.',
1956
+ )
1957
+ else
1958
+ LayoutBuilder(
1959
+ builder: (context, constraints) {
1960
+ final spacing = constraints.maxWidth >= 1100 ? 18.0 : 0.0;
1961
+ final columns = constraints.maxWidth >= 1400
1962
+ ? 2
1963
+ : (constraints.maxWidth >= 920 ? 2 : 1);
1964
+ final width = constraints.maxWidth.isFinite
1965
+ ? constraints.maxWidth
1966
+ : MediaQuery.sizeOf(context).width;
1967
+ final cardWidth = columns == 1
1968
+ ? width
1969
+ : (width - (spacing * (columns - 1))) / columns;
1970
+ return Wrap(
1971
+ spacing: spacing,
1972
+ runSpacing: 18,
1973
+ children: controller.widgets.map((item) {
1974
+ final remaining = controller.widgetRunCooldownSeconds(
1975
+ item.id,
1976
+ );
1977
+ return SizedBox(
1978
+ width: cardWidth,
1979
+ child: _AiWidgetCard(
1980
+ item: item,
1981
+ controller: controller,
1982
+ active: controller.selectedWidgetId == item.id,
1983
+ onSelect: () => controller.selectWidget(item.id),
1984
+ footer: Wrap(
1985
+ spacing: 10,
1986
+ runSpacing: 10,
1987
+ children: <Widget>[
1988
+ OutlinedButton(
1989
+ onPressed: () =>
1990
+ controller.openWidgetEditFlow(item),
1991
+ child: Text('Edit With AI'),
1992
+ ),
1993
+ OutlinedButton(
1994
+ onPressed: () =>
1995
+ controller.toggleWidgetEnabled(item),
1996
+ child: Text(item.enabled ? 'Pause' : 'Enable'),
1997
+ ),
1998
+ FilledButton(
1999
+ onPressed: remaining > 0
2000
+ ? null
2001
+ : () => controller.refreshWidgetNow(item.id),
2002
+ child: Text(
2003
+ _manualRunButtonLabel('Run Now', remaining),
2004
+ ),
2005
+ ),
2006
+ OutlinedButton(
2007
+ onPressed: () => _confirmDelete(
2008
+ context,
2009
+ title: 'Delete widget?',
2010
+ message:
2011
+ 'This removes "${item.name}" and its refresh job.',
2012
+ onConfirm: () => controller.deleteWidget(item.id),
2013
+ ),
2014
+ child: Text('Delete'),
2015
+ ),
2016
+ ],
2017
+ ),
2018
+ ),
2019
+ );
2020
+ }).toList(),
2021
+ );
2022
+ },
2023
+ ),
2024
+ ],
2025
+ );
2026
+ }
2027
+ }
2028
+
2029
+ class _AiWidgetCard extends StatefulWidget {
2030
+ const _AiWidgetCard({
2031
+ required this.item,
2032
+ this.controller,
2033
+ this.footer,
2034
+ this.active = false,
2035
+ this.compact = false,
2036
+ this.onSelect,
2037
+ });
2038
+
2039
+ final AiWidgetItem item;
2040
+ final NeoAgentController? controller;
2041
+ final Widget? footer;
2042
+ final bool active;
2043
+ final bool compact;
2044
+ final VoidCallback? onSelect;
2045
+
2046
+ @override
2047
+ State<_AiWidgetCard> createState() => _AiWidgetCardState();
2048
+ }
2049
+
2050
+ class _AiWidgetCardState extends State<_AiWidgetCard> {
2051
+ bool _expandedTasks = false;
2052
+
2053
+ @override
2054
+ Widget build(BuildContext context) {
2055
+ final item = widget.item;
2056
+ final controller = widget.controller;
2057
+ final active = widget.active;
2058
+ final compact = widget.compact;
2059
+ final onSelect = widget.onSelect;
2060
+ final footer = widget.footer;
2061
+ final snapshot = item.latestSnapshot;
2062
+ final accent = _widgetAccentColor(
2063
+ snapshot?.accentToken ?? item.template,
2064
+ surfaceColor: snapshot?.surfaceColor ?? '',
2065
+ );
2066
+ final icon = _widgetIconData(snapshot?.iconToken ?? item.template);
2067
+ final displayName = _widgetDisplayName(item.name);
2068
+ final title = _widgetPrimaryTitle(item, snapshot);
2069
+ final subtitle = _widgetSecondaryTitle(item, snapshot);
2070
+ final metric = snapshot?.metric ?? '';
2071
+ final rows = snapshot?.rows ?? const <Map<String, dynamic>>[];
2072
+ final chips = snapshot?.chips ?? const <String>[];
2073
+ final body = _widgetSummaryText(item, snapshot);
2074
+ final updatedLabel = snapshot?.generatedAtLabel ?? item.lastSnapshotLabel;
2075
+ final cadenceLabel = _widgetCadenceLabel(item.refreshCron);
2076
+
2077
+ return Container(
2078
+ decoration: BoxDecoration(
2079
+ borderRadius: BorderRadius.circular(compact ? 28 : 32),
2080
+ gradient: LinearGradient(
2081
+ begin: Alignment.topLeft,
2082
+ end: Alignment.bottomRight,
2083
+ colors: <Color>[
2084
+ Color.lerp(
2085
+ _bgCard,
2086
+ accent,
2087
+ compact ? 0.14 : 0.18,
2088
+ )!.withValues(alpha: 0.98),
2089
+ _bgCard.withValues(alpha: 0.98),
2090
+ _bgSecondary.withValues(alpha: 0.96),
2091
+ ],
2092
+ ),
2093
+ border: Border.all(
2094
+ color: active ? accent.withValues(alpha: 0.42) : _border,
2095
+ ),
2096
+ boxShadow: <BoxShadow>[
2097
+ BoxShadow(
2098
+ color: accent.withValues(alpha: compact ? 0.1 : 0.14),
2099
+ blurRadius: compact ? 22 : 32,
2100
+ offset: const Offset(0, 14),
2101
+ ),
2102
+ ],
2103
+ ),
2104
+ child: Material(
2105
+ color: Colors.transparent,
2106
+ child: InkWell(
2107
+ borderRadius: BorderRadius.circular(compact ? 28 : 32),
2108
+ onTap: onSelect,
2109
+ child: Padding(
2110
+ padding: EdgeInsets.all(compact ? 16 : 22),
2111
+ child: compact
2112
+ ? Column(
2113
+ crossAxisAlignment: CrossAxisAlignment.start,
2114
+ children: <Widget>[
2115
+ _AiWidgetAndroidPreview(
2116
+ item: item,
2117
+ accent: accent,
2118
+ icon: icon,
2119
+ snapshot: snapshot,
2120
+ compact: true,
2121
+ ),
2122
+ const SizedBox(height: 14),
2123
+ Text(
2124
+ displayName,
2125
+ maxLines: 1,
2126
+ overflow: TextOverflow.ellipsis,
2127
+ style: TextStyle(
2128
+ fontSize: 17,
2129
+ fontWeight: FontWeight.w700,
2130
+ letterSpacing: -0.3,
2131
+ ),
2132
+ ),
2133
+ const SizedBox(height: 6),
2134
+ Text(
2135
+ body,
2136
+ maxLines: 2,
2137
+ overflow: TextOverflow.ellipsis,
2138
+ style: TextStyle(color: _textSecondary),
2139
+ ),
2140
+ ],
2141
+ )
2142
+ : Column(
2143
+ crossAxisAlignment: CrossAxisAlignment.start,
2144
+ children: <Widget>[
2145
+ Row(
2146
+ crossAxisAlignment: CrossAxisAlignment.start,
2147
+ children: <Widget>[
2148
+ Container(
2149
+ width: 48,
2150
+ height: 48,
2151
+ decoration: BoxDecoration(
2152
+ color: accent.withValues(alpha: 0.16),
2153
+ borderRadius: BorderRadius.circular(18),
2154
+ border: Border.all(
2155
+ color: accent.withValues(alpha: 0.26),
2156
+ ),
2157
+ ),
2158
+ child: Icon(icon, color: accent),
2159
+ ),
2160
+ const SizedBox(width: 14),
2161
+ Expanded(
2162
+ child: Column(
2163
+ crossAxisAlignment: CrossAxisAlignment.start,
2164
+ children: <Widget>[
2165
+ Text(
2166
+ displayName,
2167
+ style: TextStyle(
2168
+ fontSize: 12,
2169
+ color: _textSecondary,
2170
+ fontWeight: FontWeight.w600,
2171
+ ),
2172
+ ),
2173
+ const SizedBox(height: 4),
2174
+ Text(
2175
+ title,
2176
+ style: TextStyle(
2177
+ fontSize: 24,
2178
+ height: 1.06,
2179
+ letterSpacing: -0.8,
2180
+ fontWeight: FontWeight.w700,
2181
+ ),
2182
+ ),
2183
+ ],
2184
+ ),
2185
+ ),
2186
+ const SizedBox(width: 12),
2187
+ _StatusPill(
2188
+ label: item.enabled ? 'Live' : 'Paused',
2189
+ color: item.enabled ? _success : _textSecondary,
2190
+ ),
2191
+ ],
2192
+ ),
2193
+ const SizedBox(height: 18),
2194
+ LayoutBuilder(
2195
+ builder: (context, constraints) {
2196
+ final stacked = constraints.maxWidth < 860;
2197
+ final infoPane = _AiWidgetInfoPane(
2198
+ item: item,
2199
+ snapshot: snapshot,
2200
+ accent: accent,
2201
+ title: title,
2202
+ subtitle: subtitle,
2203
+ body: body,
2204
+ metric: metric,
2205
+ rows: rows,
2206
+ chips: chips,
2207
+ cadenceLabel: cadenceLabel,
2208
+ updatedLabel: updatedLabel,
2209
+ );
2210
+ final previewPane = Column(
2211
+ crossAxisAlignment: CrossAxisAlignment.start,
2212
+ children: <Widget>[
2213
+ Text(
2214
+ 'Preview',
2215
+ style: TextStyle(
2216
+ color: _textSecondary,
2217
+ fontSize: 12,
2218
+ fontWeight: FontWeight.w700,
2219
+ letterSpacing: 0.3,
2220
+ ),
2221
+ ),
2222
+ const SizedBox(height: 10),
2223
+ _AiWidgetAndroidPreview(
2224
+ item: item,
2225
+ accent: accent,
2226
+ icon: icon,
2227
+ snapshot: snapshot,
2228
+ ),
2229
+ ],
2230
+ );
2231
+ if (stacked) {
2232
+ return Column(
2233
+ crossAxisAlignment: CrossAxisAlignment.start,
2234
+ children: <Widget>[
2235
+ infoPane,
2236
+ const SizedBox(height: 20),
2237
+ previewPane,
2238
+ ],
2239
+ );
2240
+ }
2241
+ return Row(
2242
+ crossAxisAlignment: CrossAxisAlignment.start,
2243
+ children: <Widget>[
2244
+ Expanded(flex: 11, child: infoPane),
2245
+ const SizedBox(width: 20),
2246
+ Expanded(flex: 10, child: previewPane),
2247
+ ],
2248
+ );
2249
+ },
2250
+ ),
2251
+ if (item.hasError) ...<Widget>[
2252
+ const SizedBox(height: 16),
2253
+ _InlineError(message: item.lastError!),
2254
+ ],
2255
+ if (item.tasks.isNotEmpty) ...<Widget>[
2256
+ const SizedBox(height: 16),
2257
+ Material(
2258
+ color: Colors.transparent,
2259
+ child: InkWell(
2260
+ borderRadius: BorderRadius.circular(12),
2261
+ onTap: () {
2262
+ setState(() {
2263
+ _expandedTasks = !_expandedTasks;
2264
+ });
2265
+ },
2266
+ child: Padding(
2267
+ padding: const EdgeInsets.symmetric(
2268
+ vertical: 8,
2269
+ horizontal: 4,
2270
+ ),
2271
+ child: Row(
2272
+ children: <Widget>[
2273
+ Expanded(
2274
+ child: Text(
2275
+ 'Tasks (${item.tasks.length})',
2276
+ style: TextStyle(
2277
+ color: accent,
2278
+ fontWeight: FontWeight.w700,
2279
+ fontSize: 14,
2280
+ ),
2281
+ ),
2282
+ ),
2283
+ Icon(
2284
+ _expandedTasks
2285
+ ? Icons.expand_less
2286
+ : Icons.expand_more,
2287
+ color: accent,
2288
+ ),
2289
+ ],
2290
+ ),
2291
+ ),
2292
+ ),
2293
+ ),
2294
+ if (_expandedTasks)
2295
+ ...item.tasks.map((task) {
2296
+ return Padding(
2297
+ padding: const EdgeInsets.only(top: 8.0),
2298
+ child: Container(
2299
+ padding: const EdgeInsets.all(12),
2300
+ decoration: BoxDecoration(
2301
+ color: Colors.white.withValues(alpha: 0.04),
2302
+ borderRadius: BorderRadius.circular(16),
2303
+ border: Border.all(
2304
+ color: Colors.white.withValues(alpha: 0.08),
2305
+ ),
2306
+ ),
2307
+ child: Row(
2308
+ children: [
2309
+ Expanded(
2310
+ child: Column(
2311
+ crossAxisAlignment:
2312
+ CrossAxisAlignment.start,
2313
+ children: [
2314
+ Text(
2315
+ task.name,
2316
+ style: TextStyle(
2317
+ color: _textPrimary,
2318
+ fontWeight: FontWeight.w600,
2319
+ ),
2320
+ ),
2321
+ if (task
2322
+ .scheduleLabel
2323
+ .isNotEmpty) ...[
2324
+ const SizedBox(height: 4),
2325
+ Text(
2326
+ task.scheduleLabel,
2327
+ style: TextStyle(
2328
+ color: _textSecondary,
2329
+ fontSize: 12,
2330
+ ),
2331
+ ),
2332
+ ],
2333
+ ],
2334
+ ),
2335
+ ),
2336
+ const SizedBox(width: 8),
2337
+ FilledButton.tonal(
2338
+ onPressed: controller != null
2339
+ ? () => controller.runTaskNow(task.id)
2340
+ : null,
2341
+ style: FilledButton.styleFrom(
2342
+ visualDensity: VisualDensity.compact,
2343
+ ),
2344
+ child: const Text('Run now'),
2345
+ ),
2346
+ ],
2347
+ ),
2348
+ ),
2349
+ );
2350
+ }),
2351
+ ],
2352
+ if (footer != null) ...<Widget>[
2353
+ const SizedBox(height: 18),
2354
+ footer,
2355
+ ],
2356
+ ],
2357
+ ),
2358
+ ),
2359
+ ),
2360
+ ),
2361
+ );
2362
+ }
2363
+ }
2364
+
2365
+ class _AiWidgetInfoPane extends StatelessWidget {
2366
+ const _AiWidgetInfoPane({
2367
+ required this.item,
2368
+ required this.snapshot,
2369
+ required this.accent,
2370
+ required this.title,
2371
+ required this.subtitle,
2372
+ required this.body,
2373
+ required this.metric,
2374
+ required this.rows,
2375
+ required this.chips,
2376
+ required this.cadenceLabel,
2377
+ required this.updatedLabel,
2378
+ });
2379
+
2380
+ final AiWidgetItem item;
2381
+ final WidgetSnapshotItem? snapshot;
2382
+ final Color accent;
2383
+ final String title;
2384
+ final String subtitle;
2385
+ final String body;
2386
+ final String metric;
2387
+ final List<Map<String, dynamic>> rows;
2388
+ final List<String> chips;
2389
+ final String cadenceLabel;
2390
+ final String updatedLabel;
2391
+
2392
+ @override
2393
+ Widget build(BuildContext context) {
2394
+ final kicker = _widgetSanitizedText(snapshot?.kicker ?? '');
2395
+ final metricLabel = _widgetSanitizedText(snapshot?.metricLabel ?? '');
2396
+ final secondaryMetric = _widgetSanitizedText(
2397
+ snapshot?.secondaryMetric ?? '',
2398
+ );
2399
+ final secondaryLabel = _widgetSanitizedText(snapshot?.secondaryLabel ?? '');
2400
+ final tertiaryMetric = _widgetSanitizedText(snapshot?.tertiaryMetric ?? '');
2401
+ final tertiaryLabel = _widgetSanitizedText(snapshot?.tertiaryLabel ?? '');
2402
+ final progress = snapshot?.progress;
2403
+ final progressValue = _widgetProgressFraction(progress);
2404
+ final hasUsefulRows = rows.any(
2405
+ (row) =>
2406
+ (row['label']?.toString() ?? '').trim().isNotEmpty ||
2407
+ (row['value']?.toString() ?? '').trim().isNotEmpty,
2408
+ );
2409
+ final hasSnapshotData =
2410
+ metric.trim().isNotEmpty ||
2411
+ secondaryMetric.isNotEmpty ||
2412
+ tertiaryMetric.isNotEmpty ||
2413
+ hasUsefulRows ||
2414
+ chips.isNotEmpty ||
2415
+ body.trim().isNotEmpty;
2416
+ return Column(
2417
+ crossAxisAlignment: CrossAxisAlignment.start,
2418
+ children: <Widget>[
2419
+ if (kicker.isNotEmpty) ...<Widget>[
2420
+ Text(
2421
+ kicker.toUpperCase(),
2422
+ style: TextStyle(
2423
+ color: accent.withValues(alpha: 0.94),
2424
+ fontSize: 11,
2425
+ fontWeight: FontWeight.w800,
2426
+ letterSpacing: 1.08,
2427
+ ),
2428
+ ),
2429
+ const SizedBox(height: 10),
2430
+ ],
2431
+ Text(
2432
+ title,
2433
+ style: TextStyle(
2434
+ fontSize: 18,
2435
+ fontWeight: FontWeight.w700,
2436
+ height: 1.1,
2437
+ letterSpacing: -0.4,
2438
+ ),
2439
+ ),
2440
+ if (subtitle.trim().isNotEmpty) ...<Widget>[
2441
+ const SizedBox(height: 8),
2442
+ Text(
2443
+ subtitle,
2444
+ style: TextStyle(fontSize: 15, color: _textSecondary, height: 1.35),
2445
+ ),
2446
+ ],
2447
+ const SizedBox(height: 18),
2448
+ if (metric.trim().isNotEmpty) ...<Widget>[
2449
+ Container(
2450
+ width: double.infinity,
2451
+ padding: const EdgeInsets.all(18),
2452
+ decoration: BoxDecoration(
2453
+ color: accent.withValues(alpha: 0.08),
2454
+ borderRadius: BorderRadius.circular(24),
2455
+ border: Border.all(color: accent.withValues(alpha: 0.16)),
2456
+ ),
2457
+ child: Column(
2458
+ crossAxisAlignment: CrossAxisAlignment.start,
2459
+ children: <Widget>[
2460
+ Text(
2461
+ metric,
2462
+ style: _displayTitleStyle(
2463
+ 42,
2464
+ ).copyWith(color: accent, letterSpacing: -1.35),
2465
+ ),
2466
+ if (metricLabel.isNotEmpty) ...<Widget>[
2467
+ const SizedBox(height: 6),
2468
+ Text(
2469
+ metricLabel,
2470
+ style: TextStyle(
2471
+ color: _textSecondary,
2472
+ fontSize: 13,
2473
+ fontWeight: FontWeight.w600,
2474
+ ),
2475
+ ),
2476
+ ],
2477
+ if (progress != null && progressValue != null) ...<Widget>[
2478
+ const SizedBox(height: 14),
2479
+ _WidgetProgressBar(
2480
+ accent: accent,
2481
+ value: progressValue,
2482
+ label: _widgetProgressLabel(progress),
2483
+ ),
2484
+ ],
2485
+ if (secondaryMetric.isNotEmpty ||
2486
+ tertiaryMetric.isNotEmpty) ...<Widget>[
2487
+ const SizedBox(height: 14),
2488
+ Wrap(
2489
+ spacing: 10,
2490
+ runSpacing: 10,
2491
+ children: <Widget>[
2492
+ if (secondaryMetric.isNotEmpty)
2493
+ _WidgetSupportingMetricCard(
2494
+ label: secondaryLabel.ifEmpty('Secondary'),
2495
+ value: secondaryMetric,
2496
+ accent: accent,
2497
+ ),
2498
+ if (tertiaryMetric.isNotEmpty)
2499
+ _WidgetSupportingMetricCard(
2500
+ label: tertiaryLabel.ifEmpty('Detail'),
2501
+ value: tertiaryMetric,
2502
+ accent: accent,
2503
+ ),
2504
+ ],
2505
+ ),
2506
+ ],
2507
+ ],
2508
+ ),
2509
+ ),
2510
+ ] else if (!hasSnapshotData) ...<Widget>[
2511
+ Container(
2512
+ width: double.infinity,
2513
+ padding: const EdgeInsets.all(18),
2514
+ decoration: BoxDecoration(
2515
+ color: Colors.white.withValues(alpha: 0.04),
2516
+ borderRadius: BorderRadius.circular(24),
2517
+ border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
2518
+ ),
2519
+ child: Text(
2520
+ 'Waiting for the first refresh. Once live data arrives, this widget will lead with the key number and keep the rest compact.',
2521
+ style: TextStyle(
2522
+ color: _textSecondary,
2523
+ height: 1.5,
2524
+ fontSize: 14,
2525
+ ),
2526
+ ),
2527
+ ),
2528
+ ],
2529
+ if (body.trim().isNotEmpty) ...<Widget>[
2530
+ const SizedBox(height: 10),
2531
+ Text(
2532
+ body,
2533
+ maxLines: 4,
2534
+ overflow: TextOverflow.ellipsis,
2535
+ style: TextStyle(color: _textPrimary, height: 1.5, fontSize: 15),
2536
+ ),
2537
+ ],
2538
+ if (hasUsefulRows) ...<Widget>[
2539
+ const SizedBox(height: 18),
2540
+ Container(
2541
+ padding: const EdgeInsets.all(14),
2542
+ decoration: BoxDecoration(
2543
+ color: Colors.white.withValues(alpha: 0.04),
2544
+ borderRadius: BorderRadius.circular(20),
2545
+ border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
2546
+ ),
2547
+ child: Column(
2548
+ children: rows.take(3).map((row) {
2549
+ return Padding(
2550
+ padding: const EdgeInsets.only(bottom: 10),
2551
+ child: Row(
2552
+ children: <Widget>[
2553
+ Expanded(
2554
+ child: Text(
2555
+ _widgetSanitizedText(
2556
+ row['label']?.toString() ?? '',
2557
+ fallback: 'Detail',
2558
+ ),
2559
+ style: TextStyle(color: _textSecondary),
2560
+ ),
2561
+ ),
2562
+ const SizedBox(width: 12),
2563
+ Text(
2564
+ _widgetSanitizedText(row['value']?.toString() ?? ''),
2565
+ style: TextStyle(
2566
+ color: _textPrimary,
2567
+ fontWeight: FontWeight.w600,
2568
+ ),
2569
+ ),
2570
+ ],
2571
+ ),
2572
+ );
2573
+ }).toList(),
2574
+ ),
2575
+ ),
2576
+ ],
2577
+ if (chips.isNotEmpty) ...<Widget>[
2578
+ const SizedBox(height: 14),
2579
+ Wrap(
2580
+ spacing: 8,
2581
+ runSpacing: 8,
2582
+ children: chips.take(3).map((chip) {
2583
+ return Container(
2584
+ padding: const EdgeInsets.symmetric(
2585
+ horizontal: 11,
2586
+ vertical: 7,
2587
+ ),
2588
+ decoration: BoxDecoration(
2589
+ color: accent.withValues(alpha: 0.12),
2590
+ borderRadius: BorderRadius.circular(999),
2591
+ border: Border.all(color: accent.withValues(alpha: 0.18)),
2592
+ ),
2593
+ child: Text(
2594
+ chip,
2595
+ style: TextStyle(
2596
+ color: _textPrimary,
2597
+ fontSize: 12,
2598
+ fontWeight: FontWeight.w600,
2599
+ ),
2600
+ ),
2601
+ );
2602
+ }).toList(),
2603
+ ),
2604
+ ],
2605
+ const SizedBox(height: 18),
2606
+ Wrap(
2607
+ spacing: 14,
2608
+ runSpacing: 14,
2609
+ children: <Widget>[
2610
+ _WidgetMetricBlock(label: 'Refreshes', value: cadenceLabel),
2611
+ _WidgetMetricBlock(label: 'Last update', value: updatedLabel),
2612
+ _WidgetMetricBlock(
2613
+ label: 'Status',
2614
+ value: item.enabled ? 'Live' : 'Paused',
2615
+ accent: item.enabled ? _success : _textSecondary,
2616
+ ),
2617
+ ],
2618
+ ),
2619
+ ],
2620
+ );
2621
+ }
2622
+ }
2623
+
2624
+ class _AiWidgetAndroidPreview extends StatelessWidget {
2625
+ const _AiWidgetAndroidPreview({
2626
+ required this.item,
2627
+ required this.accent,
2628
+ required this.icon,
2629
+ this.snapshot,
2630
+ this.compact = false,
2631
+ });
2632
+
2633
+ final AiWidgetItem item;
2634
+ final Color accent;
2635
+ final IconData icon;
2636
+ final WidgetSnapshotItem? snapshot;
2637
+ final bool compact;
2638
+
2639
+ @override
2640
+ Widget build(BuildContext context) {
2641
+ final activeSnapshot = snapshot ?? item.latestSnapshot;
2642
+ final displayName = _widgetDisplayName(item.name);
2643
+ final title = _widgetPrimaryTitle(item, activeSnapshot);
2644
+ final subtitle = _widgetSecondaryTitle(item, activeSnapshot);
2645
+ final body = _widgetSummaryText(item, activeSnapshot);
2646
+ final metric = _widgetSanitizedText(activeSnapshot?.metric ?? '');
2647
+ final metricLabel = _widgetSanitizedText(activeSnapshot?.metricLabel ?? '');
2648
+ final secondaryMetric = _widgetSanitizedText(
2649
+ activeSnapshot?.secondaryMetric ?? '',
2650
+ );
2651
+ final secondaryLabel = _widgetSanitizedText(
2652
+ activeSnapshot?.secondaryLabel ?? '',
2653
+ );
2654
+ final tertiaryMetric = _widgetSanitizedText(
2655
+ activeSnapshot?.tertiaryMetric ?? '',
2656
+ );
2657
+ final tertiaryLabel = _widgetSanitizedText(
2658
+ activeSnapshot?.tertiaryLabel ?? '',
2659
+ );
2660
+ final rows = activeSnapshot?.rows ?? const <Map<String, dynamic>>[];
2661
+ final chips = activeSnapshot?.chips ?? const <String>[];
2662
+ final progress = activeSnapshot?.progress;
2663
+ final previewRatio = _widgetPreviewAspectRatio(item.template);
2664
+ final palette = _widgetPreviewPalette(
2665
+ item.template,
2666
+ accent,
2667
+ backgroundToken: activeSnapshot?.backgroundToken ?? '',
2668
+ surfaceColor: activeSnapshot?.surfaceColor ?? '',
2669
+ );
2670
+ return AspectRatio(
2671
+ aspectRatio: previewRatio,
2672
+ child: Container(
2673
+ decoration: BoxDecoration(
2674
+ borderRadius: BorderRadius.circular(compact ? 30 : 34),
2675
+ gradient: LinearGradient(
2676
+ begin: Alignment.topLeft,
2677
+ end: Alignment.bottomRight,
2678
+ colors: palette.colors,
2679
+ ),
2680
+ border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
2681
+ boxShadow: <BoxShadow>[
2682
+ BoxShadow(
2683
+ color: palette.glow,
2684
+ blurRadius: 26,
2685
+ offset: const Offset(0, 16),
2686
+ ),
2687
+ ],
2688
+ ),
2689
+ child: Padding(
2690
+ padding: EdgeInsets.all(compact ? 16 : 18),
2691
+ child: Column(
2692
+ crossAxisAlignment: CrossAxisAlignment.start,
2693
+ children: <Widget>[
2694
+ Row(
2695
+ children: <Widget>[
2696
+ Container(
2697
+ width: compact ? 26 : 28,
2698
+ height: compact ? 26 : 28,
2699
+ decoration: BoxDecoration(
2700
+ color: palette.accent.withValues(alpha: 0.18),
2701
+ shape: BoxShape.circle,
2702
+ ),
2703
+ child: Icon(
2704
+ icon,
2705
+ size: compact ? 16 : 17,
2706
+ color: palette.accent,
2707
+ ),
2708
+ ),
2709
+ const SizedBox(width: 8),
2710
+ Expanded(
2711
+ child: Text(
2712
+ displayName,
2713
+ maxLines: 1,
2714
+ overflow: TextOverflow.ellipsis,
2715
+ style: TextStyle(
2716
+ color: palette.foreground.withValues(alpha: 0.96),
2717
+ fontWeight: FontWeight.w700,
2718
+ fontSize: compact ? 14 : 15,
2719
+ letterSpacing: -0.2,
2720
+ ),
2721
+ ),
2722
+ ),
2723
+ const SizedBox(width: 8),
2724
+ Icon(
2725
+ Icons.chevron_left_rounded,
2726
+ size: compact ? 18 : 20,
2727
+ color: palette.foreground.withValues(alpha: 0.8),
2728
+ ),
2729
+ Icon(
2730
+ Icons.chevron_right_rounded,
2731
+ size: compact ? 18 : 20,
2732
+ color: palette.foreground.withValues(alpha: 0.8),
2733
+ ),
2734
+ ],
2735
+ ),
2736
+ const SizedBox(height: 16),
2737
+ Expanded(
2738
+ child: switch (item.template) {
2739
+ 'list' => _AiWidgetPreviewList(
2740
+ title: title,
2741
+ subtitle: subtitle,
2742
+ rows: rows,
2743
+ chips: chips,
2744
+ accent: palette.accent,
2745
+ palette: palette,
2746
+ compact: compact,
2747
+ ),
2748
+ 'summary' => _AiWidgetPreviewSummary(
2749
+ title: title,
2750
+ subtitle: subtitle,
2751
+ body: body,
2752
+ metric: metric,
2753
+ metricLabel: metricLabel,
2754
+ chips: chips,
2755
+ palette: palette,
2756
+ compact: compact,
2757
+ ),
2758
+ _ => _AiWidgetPreviewStat(
2759
+ title: title,
2760
+ subtitle: subtitle,
2761
+ metric: metric,
2762
+ metricLabel: metricLabel,
2763
+ secondaryMetric: secondaryMetric,
2764
+ secondaryLabel: secondaryLabel,
2765
+ tertiaryMetric: tertiaryMetric,
2766
+ tertiaryLabel: tertiaryLabel,
2767
+ progress: progress,
2768
+ rows: rows,
2769
+ accent: palette.accent,
2770
+ palette: palette,
2771
+ compact: compact,
2772
+ ),
2773
+ },
2774
+ ),
2775
+ ],
2776
+ ),
2777
+ ),
2778
+ ),
2779
+ );
2780
+ }
2781
+ }
2782
+
2783
+ class _AiWidgetPreviewStat extends StatelessWidget {
2784
+ const _AiWidgetPreviewStat({
2785
+ required this.title,
2786
+ required this.subtitle,
2787
+ required this.metric,
2788
+ required this.metricLabel,
2789
+ required this.secondaryMetric,
2790
+ required this.secondaryLabel,
2791
+ required this.tertiaryMetric,
2792
+ required this.tertiaryLabel,
2793
+ required this.progress,
2794
+ required this.rows,
2795
+ required this.accent,
2796
+ required this.palette,
2797
+ required this.compact,
2798
+ });
2799
+
2800
+ final String title;
2801
+ final String subtitle;
2802
+ final String metric;
2803
+ final String metricLabel;
2804
+ final String secondaryMetric;
2805
+ final String secondaryLabel;
2806
+ final String tertiaryMetric;
2807
+ final String tertiaryLabel;
2808
+ final Map<String, dynamic>? progress;
2809
+ final List<Map<String, dynamic>> rows;
2810
+ final Color accent;
2811
+ final _WidgetPreviewPalette palette;
2812
+ final bool compact;
2813
+
2814
+ @override
2815
+ Widget build(BuildContext context) {
2816
+ final values = rows
2817
+ .where(
2818
+ (row) =>
2819
+ _widgetSanitizedText(row['label']?.toString() ?? '').isNotEmpty ||
2820
+ _widgetSanitizedText(row['value']?.toString() ?? '').isNotEmpty,
2821
+ )
2822
+ .take(3)
2823
+ .toList(growable: false);
2824
+ final hasMetric = metric.trim().isNotEmpty;
2825
+ final progressValue = _widgetProgressFraction(progress);
2826
+ return LayoutBuilder(
2827
+ builder: (context, constraints) {
2828
+ final dense = compact || constraints.maxHeight < 190;
2829
+ final showSupportingPills =
2830
+ !dense && (secondaryMetric.isNotEmpty || tertiaryMetric.isNotEmpty);
2831
+ final showProgressValue = !dense ? progressValue : null;
2832
+ final visibleRows = values.take(dense ? 1 : 3).toList(growable: false);
2833
+ return Column(
2834
+ crossAxisAlignment: CrossAxisAlignment.start,
2835
+ children: <Widget>[
2836
+ if (subtitle.trim().isNotEmpty)
2837
+ Text(
2838
+ subtitle,
2839
+ maxLines: 1,
2840
+ overflow: TextOverflow.ellipsis,
2841
+ style: TextStyle(
2842
+ color: palette.muted,
2843
+ fontSize: dense ? 11 : (compact ? 12 : 13),
2844
+ ),
2845
+ ),
2846
+ SizedBox(height: dense ? 6 : 8),
2847
+ Text(
2848
+ title.trim().isNotEmpty ? title : 'Waiting for first update',
2849
+ maxLines: 1,
2850
+ overflow: TextOverflow.ellipsis,
2851
+ style: TextStyle(
2852
+ color: palette.foreground,
2853
+ fontSize: dense ? 15 : (compact ? 16 : 18),
2854
+ fontWeight: FontWeight.w600,
2855
+ letterSpacing: -0.35,
2856
+ ),
2857
+ ),
2858
+ SizedBox(height: dense ? 8 : 10),
2859
+ Text(
2860
+ hasMetric ? metric : 'Waiting for first update',
2861
+ maxLines: 1,
2862
+ overflow: TextOverflow.ellipsis,
2863
+ style: TextStyle(
2864
+ color: palette.foreground,
2865
+ fontSize: dense ? 25 : (compact ? 30 : 34),
2866
+ height: 0.96,
2867
+ fontWeight: FontWeight.w700,
2868
+ letterSpacing: -1.1,
2869
+ ),
2870
+ ),
2871
+ if (metricLabel.trim().isNotEmpty) ...<Widget>[
2872
+ SizedBox(height: dense ? 4 : 6),
2873
+ Text(
2874
+ metricLabel,
2875
+ maxLines: 1,
2876
+ overflow: TextOverflow.ellipsis,
2877
+ style: TextStyle(
2878
+ color: palette.muted,
2879
+ fontSize: dense ? 10 : (compact ? 11 : 12),
2880
+ ),
2881
+ ),
2882
+ ],
2883
+ if (showSupportingPills) ...<Widget>[
2884
+ const SizedBox(height: 12),
2885
+ Wrap(
2886
+ spacing: 8,
2887
+ runSpacing: 8,
2888
+ children: <Widget>[
2889
+ if (secondaryMetric.isNotEmpty)
2890
+ _WidgetPreviewDataPill(
2891
+ label: secondaryLabel.ifEmpty('Secondary'),
2892
+ value: secondaryMetric,
2893
+ palette: palette,
2894
+ ),
2895
+ if (tertiaryMetric.isNotEmpty)
2896
+ _WidgetPreviewDataPill(
2897
+ label: tertiaryLabel.ifEmpty('Detail'),
2898
+ value: tertiaryMetric,
2899
+ palette: palette,
2900
+ ),
2901
+ ],
2902
+ ),
2903
+ ],
2904
+ if (showProgressValue != null) ...<Widget>[
2905
+ const SizedBox(height: 12),
2906
+ _WidgetPreviewProgress(
2907
+ value: showProgressValue,
2908
+ label: _widgetProgressLabel(progress),
2909
+ palette: palette,
2910
+ ),
2911
+ ],
2912
+ if (visibleRows.isNotEmpty) ...<Widget>[
2913
+ SizedBox(height: dense ? 10 : 14),
2914
+ ...visibleRows.map(
2915
+ (row) => Padding(
2916
+ padding: EdgeInsets.only(bottom: dense ? 6 : 8),
2917
+ child: Row(
2918
+ children: <Widget>[
2919
+ Expanded(
2920
+ child: Text(
2921
+ _widgetSanitizedText(row['label']?.toString() ?? ''),
2922
+ maxLines: 1,
2923
+ overflow: TextOverflow.ellipsis,
2924
+ style: TextStyle(
2925
+ color: palette.muted,
2926
+ fontSize: dense ? 10 : (compact ? 11 : 12),
2927
+ ),
2928
+ ),
2929
+ ),
2930
+ const SizedBox(width: 8),
2931
+ Text(
2932
+ _widgetSanitizedText(row['value']?.toString() ?? ''),
2933
+ style: TextStyle(
2934
+ color: palette.foreground,
2935
+ fontSize: dense ? 11 : (compact ? 12 : 13),
2936
+ fontWeight: FontWeight.w600,
2937
+ ),
2938
+ ),
2939
+ ],
2940
+ ),
2941
+ ),
2942
+ ),
2943
+ ] else ...<Widget>[
2944
+ const Spacer(),
2945
+ Text(
2946
+ 'Waiting for first update',
2947
+ style: TextStyle(
2948
+ color: palette.muted,
2949
+ fontSize: dense ? 11 : (compact ? 12 : 13),
2950
+ ),
2951
+ ),
2952
+ SizedBox(height: dense ? 8 : 12),
2953
+ Row(
2954
+ crossAxisAlignment: CrossAxisAlignment.end,
2955
+ children: List<Widget>.generate(dense ? 6 : 8, (index) {
2956
+ final count = dense ? 6 : 8;
2957
+ final factor = (count - index) / count;
2958
+ return Padding(
2959
+ padding: const EdgeInsets.only(right: 6),
2960
+ child: Container(
2961
+ width: dense ? 7 : (compact ? 8 : 10),
2962
+ height: (dense ? 16 : (compact ? 20 : 26)) * factor + 8,
2963
+ decoration: BoxDecoration(
2964
+ color: accent.withValues(alpha: 0.62 - (index * 0.05)),
2965
+ borderRadius: BorderRadius.circular(999),
2966
+ ),
2967
+ ),
2968
+ );
2969
+ }),
2970
+ ),
2971
+ ],
2972
+ ],
2973
+ );
2974
+ },
2975
+ );
2976
+ }
2977
+ }
2978
+
2979
+ class _AiWidgetPreviewSummary extends StatelessWidget {
2980
+ const _AiWidgetPreviewSummary({
2981
+ required this.title,
2982
+ required this.subtitle,
2983
+ required this.body,
2984
+ required this.metric,
2985
+ required this.metricLabel,
2986
+ required this.chips,
2987
+ required this.palette,
2988
+ required this.compact,
2989
+ });
2990
+
2991
+ final String title;
2992
+ final String subtitle;
2993
+ final String body;
2994
+ final String metric;
2995
+ final String metricLabel;
2996
+ final List<String> chips;
2997
+ final _WidgetPreviewPalette palette;
2998
+ final bool compact;
2999
+
3000
+ @override
3001
+ Widget build(BuildContext context) {
3002
+ final topLabel = subtitle.trim().isNotEmpty ? subtitle : 'Summary';
3003
+ final headline = title.trim().isNotEmpty
3004
+ ? title
3005
+ : 'Waiting for first update';
3006
+ final copy = body.trim().isNotEmpty ? body : headline;
3007
+ return Column(
3008
+ crossAxisAlignment: CrossAxisAlignment.start,
3009
+ children: <Widget>[
3010
+ Text(
3011
+ topLabel,
3012
+ maxLines: 1,
3013
+ overflow: TextOverflow.ellipsis,
3014
+ style: TextStyle(color: palette.muted, fontSize: compact ? 11 : 12),
3015
+ ),
3016
+ const SizedBox(height: 10),
3017
+ Text(
3018
+ headline,
3019
+ maxLines: compact ? 3 : 4,
3020
+ overflow: TextOverflow.ellipsis,
3021
+ style: TextStyle(
3022
+ color: palette.foreground,
3023
+ fontSize: compact ? 20 : 24,
3024
+ height: 1.12,
3025
+ fontWeight: FontWeight.w600,
3026
+ letterSpacing: -0.6,
3027
+ ),
3028
+ ),
3029
+ if (copy != headline) ...<Widget>[
3030
+ const SizedBox(height: 10),
3031
+ Text(
3032
+ copy,
3033
+ maxLines: compact ? 3 : 4,
3034
+ overflow: TextOverflow.ellipsis,
3035
+ style: TextStyle(
3036
+ color: palette.foreground.withValues(alpha: 0.86),
3037
+ fontSize: compact ? 13 : 14,
3038
+ height: 1.34,
3039
+ ),
3040
+ ),
3041
+ ],
3042
+ if (metric.isNotEmpty) ...<Widget>[
3043
+ const Spacer(),
3044
+ _WidgetPreviewDataPill(
3045
+ label: metricLabel.ifEmpty('Now'),
3046
+ value: metric,
3047
+ palette: palette,
3048
+ ),
3049
+ const SizedBox(height: 10),
3050
+ ] else if (chips.isNotEmpty) ...<Widget>[const Spacer()],
3051
+ if (chips.isNotEmpty) ...<Widget>[
3052
+ Wrap(
3053
+ spacing: 6,
3054
+ runSpacing: 6,
3055
+ children: chips.take(2).map((chip) {
3056
+ return Container(
3057
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
3058
+ decoration: BoxDecoration(
3059
+ color: palette.chip,
3060
+ borderRadius: BorderRadius.circular(999),
3061
+ ),
3062
+ child: Text(
3063
+ chip,
3064
+ style: TextStyle(
3065
+ color: palette.foreground.withValues(alpha: 0.94),
3066
+ fontSize: 11,
3067
+ fontWeight: FontWeight.w600,
3068
+ ),
3069
+ ),
3070
+ );
3071
+ }).toList(),
3072
+ ),
3073
+ ],
3074
+ ],
3075
+ );
3076
+ }
3077
+ }
3078
+
3079
+ class _AiWidgetPreviewList extends StatelessWidget {
3080
+ const _AiWidgetPreviewList({
3081
+ required this.title,
3082
+ required this.subtitle,
3083
+ required this.rows,
3084
+ required this.chips,
3085
+ required this.accent,
3086
+ required this.palette,
3087
+ required this.compact,
3088
+ });
3089
+
3090
+ final String title;
3091
+ final String subtitle;
3092
+ final List<Map<String, dynamic>> rows;
3093
+ final List<String> chips;
3094
+ final Color accent;
3095
+ final _WidgetPreviewPalette palette;
3096
+ final bool compact;
3097
+
3098
+ @override
3099
+ Widget build(BuildContext context) {
3100
+ final entries = rows.isEmpty
3101
+ ? chips
3102
+ .map((chip) => <String, dynamic>{'label': chip, 'value': ''})
3103
+ .toList(growable: false)
3104
+ : rows.take(4).toList(growable: false);
3105
+ if (entries.isEmpty) {
3106
+ return Align(
3107
+ alignment: Alignment.centerLeft,
3108
+ child: Text(
3109
+ 'Waiting for items',
3110
+ style: TextStyle(color: palette.muted, fontSize: compact ? 13 : 14),
3111
+ ),
3112
+ );
3113
+ }
3114
+ return Column(
3115
+ crossAxisAlignment: CrossAxisAlignment.start,
3116
+ children: <Widget>[
3117
+ if (subtitle.trim().isNotEmpty)
3118
+ Text(
3119
+ subtitle,
3120
+ maxLines: 1,
3121
+ overflow: TextOverflow.ellipsis,
3122
+ style: TextStyle(color: palette.muted, fontSize: compact ? 11 : 12),
3123
+ ),
3124
+ if (title.trim().isNotEmpty) ...<Widget>[
3125
+ const SizedBox(height: 6),
3126
+ Text(
3127
+ title,
3128
+ maxLines: 1,
3129
+ overflow: TextOverflow.ellipsis,
3130
+ style: TextStyle(
3131
+ color: palette.foreground,
3132
+ fontSize: compact ? 18 : 20,
3133
+ fontWeight: FontWeight.w600,
3134
+ letterSpacing: -0.4,
3135
+ ),
3136
+ ),
3137
+ const SizedBox(height: 12),
3138
+ ],
3139
+ ...entries.map((row) {
3140
+ final label = _widgetSanitizedText(
3141
+ row['label']?.toString() ?? '',
3142
+ fallback: 'Item',
3143
+ );
3144
+ final value = _widgetSanitizedText(row['value']?.toString() ?? '');
3145
+ return Padding(
3146
+ padding: const EdgeInsets.only(bottom: 10),
3147
+ child: Row(
3148
+ children: <Widget>[
3149
+ Container(
3150
+ width: compact ? 18 : 20,
3151
+ height: compact ? 18 : 20,
3152
+ decoration: BoxDecoration(
3153
+ color: accent.withValues(alpha: 0.22),
3154
+ shape: BoxShape.circle,
3155
+ ),
3156
+ child: Icon(
3157
+ Icons.check_rounded,
3158
+ size: compact ? 12 : 14,
3159
+ color: accent,
3160
+ ),
3161
+ ),
3162
+ const SizedBox(width: 10),
3163
+ Expanded(
3164
+ child: Text(
3165
+ label,
3166
+ maxLines: 1,
3167
+ overflow: TextOverflow.ellipsis,
3168
+ style: TextStyle(
3169
+ color: palette.foreground,
3170
+ fontSize: compact ? 15 : 16,
3171
+ fontWeight: FontWeight.w500,
3172
+ ),
3173
+ ),
3174
+ ),
3175
+ if (value.isNotEmpty) ...<Widget>[
3176
+ const SizedBox(width: 8),
3177
+ Text(
3178
+ value,
3179
+ style: TextStyle(
3180
+ color: palette.muted,
3181
+ fontSize: compact ? 12 : 13,
3182
+ ),
3183
+ ),
3184
+ ],
3185
+ ],
3186
+ ),
3187
+ );
3188
+ }),
3189
+ ],
3190
+ );
3191
+ }
3192
+ }
3193
+
3194
+ class _WidgetSupportingMetricCard extends StatelessWidget {
3195
+ const _WidgetSupportingMetricCard({
3196
+ required this.label,
3197
+ required this.value,
3198
+ required this.accent,
3199
+ });
3200
+
3201
+ final String label;
3202
+ final String value;
3203
+ final Color accent;
3204
+
3205
+ @override
3206
+ Widget build(BuildContext context) {
3207
+ return Container(
3208
+ constraints: const BoxConstraints(minWidth: 110),
3209
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
3210
+ decoration: BoxDecoration(
3211
+ color: accent.withValues(alpha: 0.08),
3212
+ borderRadius: BorderRadius.circular(18),
3213
+ border: Border.all(color: accent.withValues(alpha: 0.16)),
3214
+ ),
3215
+ child: Column(
3216
+ crossAxisAlignment: CrossAxisAlignment.start,
3217
+ children: <Widget>[
3218
+ Text(
3219
+ label,
3220
+ style: TextStyle(
3221
+ color: _textSecondary,
3222
+ fontSize: 11,
3223
+ fontWeight: FontWeight.w700,
3224
+ ),
3225
+ ),
3226
+ const SizedBox(height: 4),
3227
+ Text(
3228
+ value,
3229
+ style: TextStyle(
3230
+ color: _textPrimary,
3231
+ fontSize: 14,
3232
+ fontWeight: FontWeight.w700,
3233
+ ),
3234
+ ),
3235
+ ],
3236
+ ),
3237
+ );
3238
+ }
3239
+ }
3240
+
3241
+ class _WidgetProgressBar extends StatelessWidget {
3242
+ const _WidgetProgressBar({
3243
+ required this.accent,
3244
+ required this.value,
3245
+ required this.label,
3246
+ });
3247
+
3248
+ final Color accent;
3249
+ final double value;
3250
+ final String label;
3251
+
3252
+ @override
3253
+ Widget build(BuildContext context) {
3254
+ return Column(
3255
+ crossAxisAlignment: CrossAxisAlignment.start,
3256
+ children: <Widget>[
3257
+ ClipRRect(
3258
+ borderRadius: BorderRadius.circular(999),
3259
+ child: LinearProgressIndicator(
3260
+ value: value,
3261
+ minHeight: 8,
3262
+ backgroundColor: Colors.white.withValues(alpha: 0.08),
3263
+ valueColor: AlwaysStoppedAnimation<Color>(accent),
3264
+ ),
3265
+ ),
3266
+ if (label.isNotEmpty) ...<Widget>[
3267
+ const SizedBox(height: 6),
3268
+ Text(
3269
+ label,
3270
+ style: TextStyle(
3271
+ color: _textSecondary,
3272
+ fontSize: 12,
3273
+ fontWeight: FontWeight.w600,
3274
+ ),
3275
+ ),
3276
+ ],
3277
+ ],
3278
+ );
3279
+ }
3280
+ }
3281
+
3282
+ class _WidgetPreviewDataPill extends StatelessWidget {
3283
+ const _WidgetPreviewDataPill({
3284
+ required this.label,
3285
+ required this.value,
3286
+ required this.palette,
3287
+ });
3288
+
3289
+ final String label;
3290
+ final String value;
3291
+ final _WidgetPreviewPalette palette;
3292
+
3293
+ @override
3294
+ Widget build(BuildContext context) {
3295
+ return Container(
3296
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
3297
+ decoration: BoxDecoration(
3298
+ color: palette.chip,
3299
+ borderRadius: BorderRadius.circular(16),
3300
+ border: Border.all(color: palette.foreground.withValues(alpha: 0.08)),
3301
+ ),
3302
+ child: Column(
3303
+ crossAxisAlignment: CrossAxisAlignment.start,
3304
+ children: <Widget>[
3305
+ Text(
3306
+ label,
3307
+ style: TextStyle(
3308
+ color: palette.muted,
3309
+ fontSize: 10,
3310
+ fontWeight: FontWeight.w700,
3311
+ ),
3312
+ ),
3313
+ const SizedBox(height: 3),
3314
+ Text(
3315
+ value,
3316
+ style: TextStyle(
3317
+ color: palette.foreground,
3318
+ fontSize: 12,
3319
+ fontWeight: FontWeight.w700,
3320
+ ),
3321
+ ),
3322
+ ],
3323
+ ),
3324
+ );
3325
+ }
3326
+ }
3327
+
3328
+ class _WidgetPreviewProgress extends StatelessWidget {
3329
+ const _WidgetPreviewProgress({
3330
+ required this.value,
3331
+ required this.label,
3332
+ required this.palette,
3333
+ });
3334
+
3335
+ final double value;
3336
+ final String label;
3337
+ final _WidgetPreviewPalette palette;
3338
+
3339
+ @override
3340
+ Widget build(BuildContext context) {
3341
+ return Column(
3342
+ crossAxisAlignment: CrossAxisAlignment.start,
3343
+ children: <Widget>[
3344
+ ClipRRect(
3345
+ borderRadius: BorderRadius.circular(999),
3346
+ child: LinearProgressIndicator(
3347
+ value: value,
3348
+ minHeight: 7,
3349
+ backgroundColor: Colors.white.withValues(alpha: 0.1),
3350
+ valueColor: AlwaysStoppedAnimation<Color>(palette.accent),
3351
+ ),
3352
+ ),
3353
+ if (label.isNotEmpty) ...<Widget>[
3354
+ const SizedBox(height: 6),
3355
+ Text(
3356
+ label,
3357
+ style: TextStyle(
3358
+ color: palette.muted,
3359
+ fontSize: 11,
3360
+ fontWeight: FontWeight.w600,
3361
+ ),
3362
+ ),
3363
+ ],
3364
+ ],
3365
+ );
3366
+ }
3367
+ }
3368
+
3369
+ class _WidgetMetricBlock extends StatelessWidget {
3370
+ const _WidgetMetricBlock({
3371
+ required this.label,
3372
+ required this.value,
3373
+ this.accent,
3374
+ });
3375
+
3376
+ final String label;
3377
+ final String value;
3378
+ final Color? accent;
3379
+
3380
+ @override
3381
+ Widget build(BuildContext context) {
3382
+ return Container(
3383
+ constraints: const BoxConstraints(minWidth: 120),
3384
+ child: Column(
3385
+ crossAxisAlignment: CrossAxisAlignment.start,
3386
+ children: <Widget>[
3387
+ Text(
3388
+ label,
3389
+ style: TextStyle(
3390
+ color: _textSecondary,
3391
+ fontSize: 12,
3392
+ fontWeight: FontWeight.w600,
3393
+ ),
3394
+ ),
3395
+ const SizedBox(height: 4),
3396
+ Text(
3397
+ value,
3398
+ style: TextStyle(
3399
+ color: accent ?? _textPrimary,
3400
+ fontSize: 14,
3401
+ fontWeight: FontWeight.w700,
3402
+ ),
3403
+ ),
3404
+ ],
3405
+ ),
3406
+ );
3407
+ }
3408
+ }
3409
+
3410
+ class _WidgetPreviewPalette {
3411
+ const _WidgetPreviewPalette({
3412
+ required this.colors,
3413
+ required this.accent,
3414
+ required this.foreground,
3415
+ required this.muted,
3416
+ required this.chip,
3417
+ required this.glow,
3418
+ });
3419
+
3420
+ final List<Color> colors;
3421
+ final Color accent;
3422
+ final Color foreground;
3423
+ final Color muted;
3424
+ final Color chip;
3425
+ final Color glow;
3426
+ }
3427
+
3428
+ Color _widgetAccentColor(String token, {String surfaceColor = ''}) {
3429
+ final surfaceOverride = _widgetColorFromHex(surfaceColor);
3430
+ if (surfaceOverride != null) {
3431
+ return Color.lerp(surfaceOverride, Colors.white, 0.16)!;
3432
+ }
3433
+ switch (token.trim().toLowerCase()) {
3434
+ case 'warning':
3435
+ case 'sun':
3436
+ case 'sunny':
3437
+ case 'weather':
3438
+ return _warning;
3439
+ case 'success':
3440
+ case 'health':
3441
+ case 'growth':
3442
+ case 'battery':
3443
+ case 'electric':
3444
+ return _success;
3445
+ case 'alert':
3446
+ case 'error':
3447
+ case 'storm':
3448
+ return _danger;
3449
+ case 'sky':
3450
+ case 'ocean':
3451
+ case 'summary':
3452
+ case 'rain':
3453
+ case 'cloud':
3454
+ return _accentAlt;
3455
+ case 'night':
3456
+ return const Color(0xFFB7C9FF);
3457
+ default:
3458
+ return _accent;
3459
+ }
3460
+ }
3461
+
3462
+ IconData _widgetIconData(String token) {
3463
+ switch (token.trim().toLowerCase()) {
3464
+ case 'weather':
3465
+ case 'sun':
3466
+ case 'sunny':
3467
+ return Icons.wb_sunny_outlined;
3468
+ case 'rain':
3469
+ case 'storm':
3470
+ return Icons.thunderstorm_outlined;
3471
+ case 'cloud':
3472
+ return Icons.cloud_outlined;
3473
+ case 'vehicle':
3474
+ case 'car':
3475
+ return Icons.directions_car_outlined;
3476
+ case 'battery':
3477
+ case 'electric':
3478
+ return Icons.battery_charging_full_rounded;
3479
+ case 'list':
3480
+ case 'agenda':
3481
+ return Icons.view_list_outlined;
3482
+ case 'health':
3483
+ return Icons.favorite_outline;
3484
+ case 'summary':
3485
+ return Icons.notes_outlined;
3486
+ default:
3487
+ return Icons.dashboard_customize_outlined;
3488
+ }
3489
+ }
3490
+
3491
+ String _manualRunButtonLabel(String label, int remainingSeconds) {
3492
+ if (remainingSeconds <= 0) {
3493
+ return label;
3494
+ }
3495
+ return '$label (${remainingSeconds}s)';
3496
+ }
3497
+
3498
+ String _widgetSanitizedText(String value, {String fallback = ''}) {
3499
+ final normalized = value.trim();
3500
+ if (normalized.isEmpty || normalized.toLowerCase() == 'null') {
3501
+ return fallback;
3502
+ }
3503
+ return normalized;
3504
+ }
3505
+
3506
+ String _widgetDisplayName(String raw) {
3507
+ final normalized = raw
3508
+ .trim()
3509
+ .replaceAll(RegExp(r'[_-]+'), ' ')
3510
+ .replaceAll(RegExp(r'\s+'), ' ');
3511
+ if (normalized.isEmpty) {
3512
+ return 'AI Widget';
3513
+ }
3514
+ return normalized
3515
+ .split(' ')
3516
+ .where((part) => part.isNotEmpty)
3517
+ .map((part) {
3518
+ if (part.length <= 2 && part.toUpperCase() == part) {
3519
+ return part;
3520
+ }
3521
+ return '${part[0].toUpperCase()}${part.substring(1)}';
3522
+ })
3523
+ .join(' ');
3524
+ }
3525
+
3526
+ String _widgetPrimaryTitle(AiWidgetItem item, WidgetSnapshotItem? snapshot) {
3527
+ final snapshotTitle = _widgetSanitizedText(snapshot?.title ?? '');
3528
+ if (snapshotTitle.isNotEmpty) {
3529
+ return snapshotTitle;
3530
+ }
3531
+ return _widgetDisplayName(item.name);
3532
+ }
3533
+
3534
+ String _widgetSecondaryTitle(AiWidgetItem item, WidgetSnapshotItem? snapshot) {
3535
+ final kicker = _widgetSanitizedText(snapshot?.kicker ?? '');
3536
+ if (kicker.isNotEmpty) {
3537
+ return kicker;
3538
+ }
3539
+ final subtitle = _widgetSanitizedText(snapshot?.subtitle ?? '');
3540
+ if (subtitle.isNotEmpty) {
3541
+ return subtitle;
3542
+ }
3543
+ final metricLabel = _widgetSanitizedText(snapshot?.metricLabel ?? '');
3544
+ if (metricLabel.isNotEmpty) {
3545
+ return metricLabel;
3546
+ }
3547
+ if (snapshot != null) {
3548
+ return _widgetDisplayName(item.name);
3549
+ }
3550
+ return 'Waiting for the first update';
3551
+ }
3552
+
3553
+ String _widgetSummaryText(AiWidgetItem item, WidgetSnapshotItem? snapshot) {
3554
+ final body = _widgetSanitizedText(snapshot?.body ?? '');
3555
+ if (body.isNotEmpty) {
3556
+ return body;
3557
+ }
3558
+ final supportingFacts = <String>[
3559
+ _widgetLabeledValue(
3560
+ snapshot?.secondaryLabel ?? '',
3561
+ snapshot?.secondaryMetric ?? '',
3562
+ ),
3563
+ _widgetLabeledValue(
3564
+ snapshot?.tertiaryLabel ?? '',
3565
+ snapshot?.tertiaryMetric ?? '',
3566
+ ),
3567
+ ].where((entry) => entry.isNotEmpty).toList(growable: false);
3568
+ if (supportingFacts.isNotEmpty) {
3569
+ return supportingFacts.join(' • ');
3570
+ }
3571
+ final rowSummary = snapshot?.rows
3572
+ .map(
3573
+ (row) => _widgetLabeledValue(
3574
+ row['label']?.toString() ?? '',
3575
+ row['value']?.toString() ?? '',
3576
+ ),
3577
+ )
3578
+ .where((entry) => entry.isNotEmpty)
3579
+ .take(2)
3580
+ .join(' • ');
3581
+ if (rowSummary != null && rowSummary.isNotEmpty) {
3582
+ return rowSummary;
3583
+ }
3584
+ final description = _widgetSanitizedText(
3585
+ item.definition['description']?.toString() ?? '',
3586
+ );
3587
+ if (description.isNotEmpty) {
3588
+ return description;
3589
+ }
3590
+ final prompt = _widgetSanitizedText(item.prompt);
3591
+ if (prompt.isNotEmpty) {
3592
+ return prompt;
3593
+ }
3594
+ return snapshot == null
3595
+ ? 'Waiting for the first update.'
3596
+ : 'Opens the latest widget snapshot everywhere you use NeoAgent.';
3597
+ }
3598
+
3599
+ String _widgetCadenceLabel(String cron) {
3600
+ final normalized = cron.trim();
3601
+ final parts = normalized.split(RegExp(r'\s+'));
3602
+ if (parts.length != 5) {
3603
+ return normalized.isEmpty ? 'Refreshes on schedule' : normalized;
3604
+ }
3605
+ final minute = parts[0];
3606
+ final hour = parts[1];
3607
+ final dayOfWeek = parts[4];
3608
+ if (minute == '0' && hour == '*' && parts[2] == '*' && parts[3] == '*') {
3609
+ return 'Hourly';
3610
+ }
3611
+ if (minute == '0' &&
3612
+ hour.startsWith('*/') &&
3613
+ parts[2] == '*' &&
3614
+ parts[3] == '*') {
3615
+ final interval = int.tryParse(hour.substring(2));
3616
+ if (interval != null && interval > 1) {
3617
+ return 'Every $interval hours';
3618
+ }
3619
+ }
3620
+ if (minute != '*' &&
3621
+ hour != '*' &&
3622
+ parts[2] == '*' &&
3623
+ parts[3] == '*' &&
3624
+ dayOfWeek == '*') {
3625
+ final minuteValue = int.tryParse(minute);
3626
+ final hourValue = int.tryParse(hour);
3627
+ if (minuteValue != null && hourValue != null) {
3628
+ final localizations = WidgetsBinding.instance.platformDispatcher.locale;
3629
+ final formattedMinute = minuteValue.toString().padLeft(2, '0');
3630
+ final formattedHour = hourValue.toString().padLeft(2, '0');
3631
+ if (localizations.languageCode.toLowerCase() == 'en') {
3632
+ return 'Daily at $formattedHour:$formattedMinute';
3633
+ }
3634
+ return 'Daily at $formattedHour:$formattedMinute';
3635
+ }
3636
+ }
3637
+ return normalized;
3638
+ }
3639
+
3640
+ double _widgetPreviewAspectRatio(String template) {
3641
+ switch (template.trim().toLowerCase()) {
3642
+ case 'summary':
3643
+ return 1.9;
3644
+ case 'list':
3645
+ return 1.08;
3646
+ default:
3647
+ return 1.18;
3648
+ }
3649
+ }
3650
+
3651
+ String _widgetLabeledValue(String label, String value) {
3652
+ final safeLabel = _widgetSanitizedText(label);
3653
+ final safeValue = _widgetSanitizedText(value);
3654
+ if (safeLabel.isEmpty) return safeValue;
3655
+ if (safeValue.isEmpty) return safeLabel;
3656
+ return '$safeLabel $safeValue';
3657
+ }
3658
+
3659
+ Color? _widgetColorFromHex(String raw) {
3660
+ final normalized = raw.trim();
3661
+ if (normalized.isEmpty) {
3662
+ return null;
3663
+ }
3664
+ final hex = normalized.startsWith('#') ? normalized.substring(1) : normalized;
3665
+ if (!RegExp(r'^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$').hasMatch(hex)) {
3666
+ return null;
3667
+ }
3668
+ final value = int.parse(hex.length == 6 ? 'FF$hex' : hex, radix: 16);
3669
+ return Color(value);
3670
+ }
3671
+
3672
+ Color _widgetBackgroundSeed(String token, Color accent) {
3673
+ switch (token.trim().toLowerCase()) {
3674
+ case 'sun':
3675
+ case 'sunny':
3676
+ return const Color(0xFFD59B4E);
3677
+ case 'rain':
3678
+ return const Color(0xFF5274A7);
3679
+ case 'storm':
3680
+ return const Color(0xFF50597A);
3681
+ case 'cloud':
3682
+ return const Color(0xFF71809A);
3683
+ case 'night':
3684
+ return const Color(0xFF42507B);
3685
+ case 'electric':
3686
+ case 'battery':
3687
+ return const Color(0xFF37C990);
3688
+ case 'vehicle':
3689
+ return const Color(0xFF5B6E88);
3690
+ default:
3691
+ return accent;
3692
+ }
3693
+ }
3694
+
3695
+ double? _widgetProgressFraction(Map<String, dynamic>? progress) {
3696
+ if (progress == null) {
3697
+ return null;
3698
+ }
3699
+ final value = double.tryParse(progress['value']?.toString() ?? '');
3700
+ final max = double.tryParse(progress['max']?.toString() ?? '');
3701
+ if (value == null || max == null || max <= 0) {
3702
+ return null;
3703
+ }
3704
+ return (value / max).clamp(0.0, 1.0);
3705
+ }
3706
+
3707
+ String _widgetProgressLabel(Map<String, dynamic>? progress) {
3708
+ if (progress == null) {
3709
+ return '';
3710
+ }
3711
+ final explicit = _widgetSanitizedText(progress['label']?.toString() ?? '');
3712
+ if (explicit.isNotEmpty) {
3713
+ return explicit;
3714
+ }
3715
+ final value = progress['value']?.toString() ?? '';
3716
+ final max = progress['max']?.toString() ?? '';
3717
+ if (value.isNotEmpty && max.isNotEmpty) {
3718
+ return '$value / $max';
3719
+ }
3720
+ return '';
3721
+ }
3722
+
3723
+ _WidgetPreviewPalette _widgetPreviewPalette(
3724
+ String template,
3725
+ Color accent, {
3726
+ String backgroundToken = '',
3727
+ String surfaceColor = '',
3728
+ }) {
3729
+ final surfaceOverride = _widgetColorFromHex(surfaceColor);
3730
+ final seed =
3731
+ surfaceOverride ?? _widgetBackgroundSeed(backgroundToken, accent);
3732
+ final accentColor = Color.lerp(seed, Colors.white, 0.18)!;
3733
+ final start = switch (template.trim().toLowerCase()) {
3734
+ 'summary' => Color.lerp(seed, const Color(0xFF101B28), 0.28)!,
3735
+ 'list' => Color.lerp(seed, const Color(0xFF162130), 0.44)!,
3736
+ _ => Color.lerp(seed, const Color(0xFF121A25), 0.34)!,
3737
+ };
3738
+ final end = switch (template.trim().toLowerCase()) {
3739
+ 'summary' => Color.lerp(seed, const Color(0xFF081018), 0.74)!,
3740
+ 'list' => Color.lerp(seed, const Color(0xFF0D141F), 0.78)!,
3741
+ _ => Color.lerp(seed, const Color(0xFF0B121C), 0.8)!,
3742
+ };
3743
+ return _WidgetPreviewPalette(
3744
+ colors: <Color>[start, end],
3745
+ accent: accentColor,
3746
+ foreground: Colors.white,
3747
+ muted: Colors.white.withValues(alpha: 0.72),
3748
+ chip: Colors.white.withValues(alpha: 0.11),
3749
+ glow: accentColor.withValues(alpha: 0.18),
3750
+ );
3751
+ }
3752
+
3753
+ class _TaskTriggerOption {
3754
+ const _TaskTriggerOption({
3755
+ required this.type,
3756
+ required this.section,
3757
+ required this.label,
3758
+ required this.description,
3759
+ required this.icon,
3760
+ });
3761
+
3762
+ final String type;
3763
+ final String section;
3764
+ final String label;
3765
+ final String description;
3766
+ final IconData icon;
3767
+ }
3768
+
3769
+ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
3770
+ _TaskTriggerOption(
3771
+ type: 'manual',
3772
+ section: 'On Demand',
3773
+ label: 'Manual Trigger',
3774
+ description: 'Runs only when you press Run Now.',
3775
+ icon: Icons.play_circle_outline_rounded,
3776
+ ),
3777
+ _TaskTriggerOption(
3778
+ type: 'schedule',
3779
+ section: 'Time',
3780
+ label: 'Schedule',
3781
+ description: 'Cron-based recurring runs and one-time timed execution.',
3782
+ icon: Icons.schedule_rounded,
3783
+ ),
3784
+ _TaskTriggerOption(
3785
+ type: 'gmail_message_received',
3786
+ section: 'Email',
3787
+ label: 'Gmail Message Received',
3788
+ description: 'Run when a matching Gmail message arrives.',
3789
+ icon: Icons.mail_rounded,
3790
+ ),
3791
+ _TaskTriggerOption(
3792
+ type: 'outlook_email_received',
3793
+ section: 'Email',
3794
+ label: 'Outlook Email Received',
3795
+ description: 'Run when a matching Outlook email arrives.',
3796
+ icon: Icons.markunread_rounded,
3797
+ ),
3798
+ _TaskTriggerOption(
3799
+ type: 'slack_message_received',
3800
+ section: 'Messaging',
3801
+ label: 'Slack Message Received',
3802
+ description: 'Run when a Slack message matches the selected scope.',
3803
+ icon: Icons.forum_rounded,
3804
+ ),
3805
+ _TaskTriggerOption(
3806
+ type: 'teams_message_received',
3807
+ section: 'Messaging',
3808
+ label: 'Teams Message Received',
3809
+ description: 'Run when a Teams chat message matches the selected scope.',
3810
+ icon: Icons.groups_rounded,
3811
+ ),
3812
+ _TaskTriggerOption(
3813
+ type: 'weather_event',
3814
+ section: 'Environment',
3815
+ label: 'Weather Event',
3816
+ description:
3817
+ 'Run when configured weather events are forecast for a location.',
3818
+ icon: Icons.cloudy_snowing,
3819
+ ),
3820
+ _TaskTriggerOption(
3821
+ type: 'whatsapp_personal_message_received',
3822
+ section: 'Messaging',
3823
+ label: 'WhatsApp Personal Message Received',
3824
+ description: 'Run on inbound personal WhatsApp messages.',
3825
+ icon: Icons.chat_bubble_rounded,
3826
+ ),
3827
+ ];
3828
+
3829
+ _TaskTriggerOption _taskTriggerOptionForType(String type) {
3830
+ return _taskTriggerOptions.firstWhere(
3831
+ (option) => option.type == type,
3832
+ orElse: () => _taskTriggerOptions.first,
3833
+ );
3834
+ }
3835
+
3836
+ Future<String?> _pickTaskTriggerType(
3837
+ BuildContext context,
3838
+ String selectedType,
3839
+ ) {
3840
+ final optionsBySection = <String, List<_TaskTriggerOption>>{};
3841
+ for (final option in _taskTriggerOptions) {
3842
+ optionsBySection
3843
+ .putIfAbsent(option.section, () => <_TaskTriggerOption>[])
3844
+ .add(option);
3845
+ }
3846
+
3847
+ return showDialog<String>(
3848
+ context: context,
3849
+ builder: (context) {
3850
+ return Dialog(
3851
+ backgroundColor: _bgCard,
3852
+ child: ConstrainedBox(
3853
+ constraints: const BoxConstraints(maxWidth: 720, maxHeight: 720),
3854
+ child: Padding(
3855
+ padding: const EdgeInsets.all(24),
3856
+ child: Column(
3857
+ crossAxisAlignment: CrossAxisAlignment.start,
3858
+ children: <Widget>[
3859
+ Text(
3860
+ 'Select Trigger',
3861
+ style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
3862
+ ),
3863
+ const SizedBox(height: 8),
3864
+ Text(
3865
+ 'Choose how this task should start. Manual runs only on Run Now. Schedule is time-based. Integration triggers fire from connected official apps.',
3866
+ style: TextStyle(color: _textSecondary, height: 1.45),
3867
+ ),
3868
+ const SizedBox(height: 18),
3869
+ Expanded(
3870
+ child: ListView(
3871
+ children: optionsBySection.entries.map((entry) {
3872
+ return Padding(
3873
+ padding: const EdgeInsets.only(bottom: 18),
3874
+ child: Column(
3875
+ crossAxisAlignment: CrossAxisAlignment.start,
3876
+ children: <Widget>[
3877
+ Text(
3878
+ entry.key.toUpperCase(),
3879
+ style: TextStyle(
3880
+ color: _textSecondary,
3881
+ fontSize: 12,
3882
+ fontWeight: FontWeight.w700,
3883
+ letterSpacing: 1.4,
3884
+ ),
3885
+ ),
3886
+ const SizedBox(height: 10),
3887
+ ...entry.value.map((option) {
3888
+ final isSelected = option.type == selectedType;
3889
+ return Padding(
3890
+ padding: const EdgeInsets.only(bottom: 10),
3891
+ child: InkWell(
3892
+ borderRadius: BorderRadius.circular(18),
3893
+ onTap: () =>
3894
+ Navigator.of(context).pop(option.type),
3895
+ child: AnimatedContainer(
3896
+ duration: const Duration(milliseconds: 160),
3897
+ padding: const EdgeInsets.all(16),
3898
+ decoration: BoxDecoration(
3899
+ borderRadius: BorderRadius.circular(18),
3900
+ border: Border.all(
3901
+ color: isSelected ? _accent : _border,
3902
+ width: isSelected ? 1.6 : 1,
3903
+ ),
3904
+ gradient: isSelected
3905
+ ? LinearGradient(
3906
+ colors: <Color>[
3907
+ _accent.withValues(alpha: 0.18),
3908
+ _accent.withValues(alpha: 0.05),
3909
+ ],
3910
+ begin: Alignment.topLeft,
3911
+ end: Alignment.bottomRight,
3912
+ )
3913
+ : null,
3914
+ color: isSelected
3915
+ ? null
3916
+ : _bgCard.withValues(alpha: 0.72),
3917
+ boxShadow: isSelected
3918
+ ? <BoxShadow>[
3919
+ BoxShadow(
3920
+ color: _accent.withValues(
3921
+ alpha: 0.12,
3922
+ ),
3923
+ blurRadius: 24,
3924
+ offset: const Offset(0, 10),
3925
+ ),
3926
+ ]
3927
+ : null,
3928
+ ),
3929
+ child: Row(
3930
+ crossAxisAlignment:
3931
+ CrossAxisAlignment.start,
3932
+ children: <Widget>[
3933
+ Container(
3934
+ width: 44,
3935
+ height: 44,
3936
+ decoration: BoxDecoration(
3937
+ color: isSelected
3938
+ ? _accent.withValues(
3939
+ alpha: 0.16,
3940
+ )
3941
+ : _bgCard,
3942
+ borderRadius: BorderRadius.circular(
3943
+ 14,
3944
+ ),
3945
+ ),
3946
+ child: Icon(
3947
+ option.icon,
3948
+ color: isSelected
3949
+ ? _accent
3950
+ : _textSecondary,
3951
+ ),
3952
+ ),
3953
+ const SizedBox(width: 14),
3954
+ Expanded(
3955
+ child: Column(
3956
+ crossAxisAlignment:
3957
+ CrossAxisAlignment.start,
3958
+ children: <Widget>[
3959
+ Text(
3960
+ option.label,
3961
+ style: TextStyle(
3962
+ fontWeight: FontWeight.w700,
3963
+ fontSize: 15,
3964
+ ),
3965
+ ),
3966
+ const SizedBox(height: 5),
3967
+ Text(
3968
+ option.description,
3969
+ style: TextStyle(
3970
+ color: _textSecondary,
3971
+ height: 1.4,
3972
+ ),
3973
+ ),
3974
+ ],
3975
+ ),
3976
+ ),
3977
+ const SizedBox(width: 12),
3978
+ Icon(
3979
+ isSelected
3980
+ ? Icons.check_circle_rounded
3981
+ : Icons.arrow_forward_rounded,
3982
+ color: isSelected
3983
+ ? _accent
3984
+ : _textSecondary,
3985
+ ),
3986
+ ],
3987
+ ),
3988
+ ),
3989
+ ),
3990
+ );
3991
+ }),
3992
+ ],
3993
+ ),
3994
+ );
3995
+ }).toList(),
3996
+ ),
3997
+ ),
3998
+ const SizedBox(height: 8),
3999
+ Align(
4000
+ alignment: Alignment.centerRight,
4001
+ child: TextButton(
4002
+ onPressed: () => Navigator.of(context).pop(),
4003
+ child: const Text('Cancel'),
4004
+ ),
4005
+ ),
4006
+ ],
4007
+ ),
4008
+ ),
4009
+ ),
4010
+ );
4011
+ },
4012
+ );
4013
+ }
4014
+
4015
+ class TasksPanel extends StatefulWidget {
4016
+ const TasksPanel({super.key, required this.controller});
4017
+
4018
+ final NeoAgentController controller;
4019
+
4020
+ @override
4021
+ State<TasksPanel> createState() => _TasksPanelState();
4022
+ }
4023
+
4024
+ class _TasksPanelState extends State<TasksPanel> {
4025
+ String? _agentFilterId;
4026
+
4027
+ NeoAgentController get controller => widget.controller;
4028
+
4029
+ @override
4030
+ void didUpdateWidget(covariant TasksPanel oldWidget) {
4031
+ super.didUpdateWidget(oldWidget);
4032
+ if (_agentFilterId == null) return;
4033
+ final stillExists = controller.agentProfiles.any(
4034
+ (agent) => agent.id == _agentFilterId,
4035
+ );
4036
+ if (!stillExists) {
4037
+ _agentFilterId = null;
4038
+ }
4039
+ }
4040
+
4041
+ @override
4042
+ Widget build(BuildContext context) {
4043
+ final filteredTasks = _agentFilterId == null
4044
+ ? controller.taskItems
4045
+ : controller.taskItems
4046
+ .where((task) => task.agentId == _agentFilterId)
4047
+ .toList();
4048
+ final automationTasks = filteredTasks
4049
+ .where((task) => !task.isWidgetRefresh)
4050
+ .toList();
4051
+ final widgetTasks = filteredTasks
4052
+ .where((task) => task.isWidgetRefresh)
4053
+ .toList();
4054
+ final selectedAgentLabel = controller.agentLabelFor(_agentFilterId);
4055
+ return ListView(
4056
+ padding: _pagePadding(context),
4057
+ children: <Widget>[
4058
+ _PageTitle(
4059
+ title: 'Tasks',
4060
+ subtitle:
4061
+ 'Premium automation with schedule and integration triggers.',
4062
+ trailing: Wrap(
4063
+ spacing: 10,
4064
+ runSpacing: 10,
4065
+ children: <Widget>[
4066
+ OutlinedButton.icon(
4067
+ onPressed: controller.openWidgetCreateFlow,
4068
+ icon: Icon(Icons.dashboard_customize_outlined),
4069
+ label: Text('Create Widget'),
4070
+ ),
4071
+ FilledButton.icon(
4072
+ onPressed: () => _openTaskEditor(
4073
+ context,
4074
+ defaultAgentId: _agentFilterId ?? controller.selectedAgentId,
4075
+ ),
4076
+ icon: Icon(Icons.add),
4077
+ label: Text('Add Task'),
4078
+ ),
4079
+ ],
4080
+ ),
4081
+ ),
4082
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
4083
+ Card(
4084
+ child: Padding(
4085
+ padding: const EdgeInsets.all(14),
4086
+ child: Column(
4087
+ crossAxisAlignment: CrossAxisAlignment.start,
4088
+ children: <Widget>[
4089
+ Text(
4090
+ 'Assigned agent',
4091
+ style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700),
4092
+ ),
4093
+ const SizedBox(height: 10),
4094
+ Wrap(
4095
+ spacing: 8,
4096
+ runSpacing: 8,
4097
+ children: <Widget>[
4098
+ ChoiceChip(
4099
+ label: Text(
4100
+ 'All agents (${controller.taskItems.length})',
4101
+ ),
4102
+ selected: _agentFilterId == null,
4103
+ onSelected: (_) =>
4104
+ setState(() => _agentFilterId = null),
4105
+ ),
4106
+ ...controller.agentProfiles.map((agent) {
4107
+ final count = controller.taskItems
4108
+ .where((task) => task.agentId == agent.id)
4109
+ .length;
4110
+ return ChoiceChip(
4111
+ label: Text('${agent.displayName} ($count)'),
4112
+ selected: _agentFilterId == agent.id,
4113
+ onSelected: (_) =>
4114
+ setState(() => _agentFilterId = agent.id),
4115
+ );
4116
+ }),
4117
+ ],
4118
+ ),
4119
+ ],
4120
+ ),
4121
+ ),
4122
+ ),
4123
+ const SizedBox(height: 14),
4124
+ ],
4125
+ if (controller.taskItems.isEmpty)
4126
+ const _EmptyCard(
4127
+ title: 'No tasks yet',
4128
+ subtitle: 'Create a task with a trigger to automate regular work.',
4129
+ )
4130
+ else if (filteredTasks.isEmpty)
4131
+ _EmptyCard(
4132
+ title: 'No tasks for $selectedAgentLabel',
4133
+ subtitle: 'Create a task while this agent is selected.',
4134
+ )
4135
+ else ...<Widget>[
4136
+ if (automationTasks.isNotEmpty) ...<Widget>[
4137
+ Text('Tasks', style: _sectionEyebrowStyle()),
4138
+ const SizedBox(height: 10),
4139
+ ...automationTasks.map(_buildTaskCard),
4140
+ ],
4141
+ if (widgetTasks.isNotEmpty) ...<Widget>[
4142
+ if (automationTasks.isNotEmpty) const SizedBox(height: 18),
4143
+ Text('Managed Widget Tasks', style: _sectionEyebrowStyle()),
4144
+ const SizedBox(height: 10),
4145
+ ...widgetTasks.map(_buildWidgetTaskCard),
4146
+ ],
4147
+ ],
4148
+ ],
4149
+ );
4150
+ }
4151
+
4152
+ Widget _buildTaskCard(TaskItem task) {
4153
+ final remaining = controller.taskRunCooldownSeconds(task.id);
4154
+ return Padding(
4155
+ padding: const EdgeInsets.only(bottom: 14),
4156
+ child: Card(
4157
+ child: Padding(
4158
+ padding: const EdgeInsets.all(18),
4159
+ child: Column(
4160
+ crossAxisAlignment: CrossAxisAlignment.start,
4161
+ children: <Widget>[
4162
+ Row(
4163
+ children: <Widget>[
4164
+ Expanded(
4165
+ child: Text(
4166
+ task.name,
4167
+ style: TextStyle(
4168
+ fontSize: 16,
4169
+ fontWeight: FontWeight.w700,
4170
+ ),
4171
+ ),
4172
+ ),
4173
+ _StatusPill(
4174
+ label: task.enabled ? 'Active' : 'Paused',
4175
+ color: task.enabled ? _success : _textSecondary,
4176
+ ),
4177
+ ],
4178
+ ),
4179
+ const SizedBox(height: 10),
4180
+ Text(
4181
+ task.scheduleLabel,
4182
+ style: TextStyle(
4183
+ color: _textSecondary,
4184
+ fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
4185
+ ),
4186
+ ),
4187
+ if (task.hasModelOverride) ...<Widget>[
4188
+ const SizedBox(height: 8),
4189
+ Text(
4190
+ 'Model: ${_modelLabelForValue(task.model, controller.supportedModels)}',
4191
+ style: TextStyle(color: _textSecondary),
4192
+ ),
4193
+ ],
4194
+ const SizedBox(height: 8),
4195
+ Text(
4196
+ 'Assigned agent: ${controller.agentLabelFor(task.agentId)}',
4197
+ style: TextStyle(color: _textSecondary),
4198
+ ),
4199
+ const SizedBox(height: 8),
4200
+ Text(task.prompt, style: TextStyle(color: _textPrimary)),
4201
+ if (task.lastRunLabel.isNotEmpty) ...<Widget>[
4202
+ const SizedBox(height: 8),
4203
+ Text(
4204
+ 'Last run: ${task.lastRunLabel}',
4205
+ style: TextStyle(color: _textSecondary),
4206
+ ),
4207
+ ],
4208
+ const SizedBox(height: 14),
4209
+ Wrap(
4210
+ spacing: 10,
4211
+ runSpacing: 10,
4212
+ children: <Widget>[
4213
+ OutlinedButton(
4214
+ onPressed: () => _openTaskEditor(context, task: task),
4215
+ child: Text('Edit'),
4216
+ ),
4217
+ OutlinedButton(
4218
+ onPressed: () => controller.toggleTask(task),
4219
+ child: Text(task.enabled ? 'Pause' : 'Enable'),
4220
+ ),
4221
+ FilledButton(
4222
+ onPressed: remaining > 0
4223
+ ? null
4224
+ : () => controller.runTaskNow(task.id),
4225
+ child: Text(_manualRunButtonLabel('Run Now', remaining)),
4226
+ ),
4227
+ OutlinedButton(
4228
+ onPressed: () => _confirmDelete(
4229
+ context,
4230
+ title: 'Delete task?',
4231
+ message: 'This will remove "${task.name}".',
4232
+ onConfirm: () => controller.deleteTask(task.id),
4233
+ ),
4234
+ child: Text('Delete'),
4235
+ ),
4236
+ ],
4237
+ ),
4238
+ ],
4239
+ ),
4240
+ ),
4241
+ ),
4242
+ );
4243
+ }
4244
+
4245
+ Widget _buildWidgetTaskCard(TaskItem task) {
4246
+ AiWidgetItem? linkedWidget;
4247
+ for (final item in controller.widgets) {
4248
+ if (item.id == task.widgetId) {
4249
+ linkedWidget = item;
4250
+ break;
4251
+ }
4252
+ }
4253
+ final remaining = linkedWidget == null
4254
+ ? controller.taskRunCooldownSeconds(task.id)
4255
+ : controller.widgetRunCooldownSeconds(linkedWidget.id);
4256
+ return Padding(
4257
+ padding: const EdgeInsets.only(bottom: 14),
4258
+ child: Card(
4259
+ child: Padding(
4260
+ padding: const EdgeInsets.all(18),
4261
+ child: Column(
4262
+ crossAxisAlignment: CrossAxisAlignment.start,
4263
+ children: <Widget>[
4264
+ Row(
4265
+ children: <Widget>[
4266
+ Expanded(
4267
+ child: Text(
4268
+ linkedWidget?.name ?? task.name,
4269
+ style: TextStyle(
4270
+ fontSize: 16,
4271
+ fontWeight: FontWeight.w700,
4272
+ ),
4273
+ ),
4274
+ ),
4275
+ _StatusPill(
4276
+ label: task.enabled ? 'Active' : 'Paused',
4277
+ color: task.enabled ? _success : _textSecondary,
4278
+ ),
4279
+ ],
4280
+ ),
4281
+ const SizedBox(height: 10),
4282
+ Text(
4283
+ task.scheduleLabel,
4284
+ style: TextStyle(
4285
+ color: _textSecondary,
4286
+ fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
4287
+ ),
4288
+ ),
4289
+ const SizedBox(height: 8),
4290
+ Text(
4291
+ 'Assigned agent: ${controller.agentLabelFor(task.agentId)}',
4292
+ style: TextStyle(color: _textSecondary),
4293
+ ),
4294
+ if (linkedWidget != null) ...<Widget>[
4295
+ const SizedBox(height: 8),
4296
+ Text(
4297
+ '${linkedWidget.template} · ${linkedWidget.layoutVariant}',
4298
+ style: TextStyle(color: _textSecondary),
4299
+ ),
4300
+ const SizedBox(height: 8),
4301
+ Text(
4302
+ linkedWidget.prompt,
4303
+ style: TextStyle(color: _textPrimary),
4304
+ ),
4305
+ ],
4306
+ if (task.lastRunLabel.isNotEmpty) ...<Widget>[
4307
+ const SizedBox(height: 8),
4308
+ Text(
4309
+ 'Last run: ${task.lastRunLabel}',
4310
+ style: TextStyle(color: _textSecondary),
4311
+ ),
4312
+ ],
4313
+ const SizedBox(height: 14),
4314
+ Wrap(
4315
+ spacing: 10,
4316
+ runSpacing: 10,
4317
+ children: <Widget>[
4318
+ OutlinedButton(
4319
+ onPressed: linkedWidget == null
4320
+ ? null
4321
+ : () => controller.openWidgetEditFlow(linkedWidget!),
4322
+ child: Text('Edit With AI'),
4323
+ ),
4324
+ OutlinedButton(
4325
+ onPressed: linkedWidget == null
4326
+ ? null
4327
+ : () => controller.toggleWidgetEnabled(linkedWidget!),
4328
+ child: Text(task.enabled ? 'Pause' : 'Enable'),
4329
+ ),
4330
+ FilledButton(
4331
+ onPressed: remaining > 0
4332
+ ? null
4333
+ : (linkedWidget == null
4334
+ ? () => controller.runTaskNow(task.id)
4335
+ : () => controller.refreshWidgetNow(
4336
+ linkedWidget!.id,
4337
+ )),
4338
+ child: Text(
4339
+ _manualRunButtonLabel('Refresh Now', remaining),
4340
+ ),
4341
+ ),
4342
+ OutlinedButton(
4343
+ onPressed: linkedWidget == null
4344
+ ? null
4345
+ : () => _confirmDelete(
4346
+ context,
4347
+ title: 'Delete widget?',
4348
+ message:
4349
+ 'This will remove "${linkedWidget!.name}" and its refresh job.',
4350
+ onConfirm: () =>
4351
+ controller.deleteWidget(linkedWidget!.id),
4352
+ ),
4353
+ child: Text('Delete'),
4354
+ ),
4355
+ ],
4356
+ ),
4357
+ ],
4358
+ ),
4359
+ ),
4360
+ ),
4361
+ );
4362
+ }
4363
+
4364
+ Future<void> _openTaskEditor(
4365
+ BuildContext context, {
4366
+ TaskItem? task,
4367
+ String? defaultAgentId,
4368
+ }) async {
4369
+ final nameController = TextEditingController(text: task?.name ?? '');
4370
+ final triggerType = ValueNotifier<String>(task?.triggerType ?? 'schedule');
4371
+ final cronController = TextEditingController(
4372
+ text: task?.triggerConfig['cronExpression']?.toString() ?? '*/30 * * * *',
4373
+ );
4374
+ final runAtController = TextEditingController(
4375
+ text: task?.triggerConfig['runAt']?.toString() ?? '',
4376
+ );
4377
+ final connectionIdController = TextEditingController(
4378
+ text: task?.triggerConfig['connectionId']?.toString() ?? '',
4379
+ );
4380
+ final queryController = TextEditingController(
4381
+ text:
4382
+ task?.triggerConfig['query']?.toString() ??
4383
+ task?.triggerConfig['location']?.toString() ??
4384
+ '',
4385
+ );
4386
+ final weatherEventTypesController = TextEditingController(
4387
+ text: (() {
4388
+ final raw = task?.triggerConfig['eventTypes'];
4389
+ if (raw is List) {
4390
+ return raw.map((entry) => entry.toString()).join(', ');
4391
+ }
4392
+ return task?.triggerConfig['eventTypes']?.toString() ??
4393
+ 'rain_start, wind_alert';
4394
+ })(),
4395
+ );
4396
+ final channelController = TextEditingController(
4397
+ text:
4398
+ task?.triggerConfig['channel']?.toString() ??
4399
+ task?.triggerConfig['chatId']?.toString() ??
4400
+ '',
4401
+ );
4402
+ final senderController = TextEditingController(
4403
+ text: task?.triggerConfig['sender']?.toString() ?? '',
4404
+ );
4405
+ final promptController = TextEditingController(text: task?.prompt ?? '');
4406
+ var enabled = task?.enabled ?? true;
4407
+ var unreadOnly = task?.triggerConfig['unreadOnly'] == true;
4408
+ var ignoreGroups = task?.triggerConfig['ignoreGroups'] == true;
4409
+ var selectedModel = _ensureModelValue(
4410
+ task?.model ?? 'auto',
4411
+ controller.supportedModels,
4412
+ allowAuto: true,
4413
+ );
4414
+ var selectedAgentId =
4415
+ task?.agentId ?? defaultAgentId ?? controller.selectedAgentId;
4416
+ if (selectedAgentId != null &&
4417
+ !controller.agentProfiles.any((agent) => agent.id == selectedAgentId)) {
4418
+ selectedAgentId = controller.selectedAgentId;
4419
+ }
4420
+ if (selectedAgentId != null &&
4421
+ !controller.agentProfiles.any((agent) => agent.id == selectedAgentId)) {
4422
+ selectedAgentId = controller.agentProfiles.isEmpty
4423
+ ? null
4424
+ : controller.agentProfiles.first.id;
4425
+ }
4426
+
4427
+ await showDialog<void>(
4428
+ context: context,
4429
+ builder: (context) {
4430
+ return StatefulBuilder(
4431
+ builder: (context, setLocalState) {
4432
+ return AlertDialog(
4433
+ backgroundColor: _bgCard,
4434
+ title: Text(task == null ? 'Add Task' : 'Edit Task'),
4435
+ content: SizedBox(
4436
+ width: 680,
4437
+ child: SingleChildScrollView(
4438
+ child: Column(
4439
+ mainAxisSize: MainAxisSize.min,
4440
+ children: <Widget>[
4441
+ TextField(
4442
+ controller: nameController,
4443
+ decoration: const InputDecoration(labelText: 'Name'),
4444
+ ),
4445
+ const SizedBox(height: 12),
4446
+ ValueListenableBuilder<String>(
4447
+ valueListenable: triggerType,
4448
+ builder: (context, selectedTriggerType, _) {
4449
+ final option = _taskTriggerOptionForType(
4450
+ selectedTriggerType,
4451
+ );
4452
+ return InkWell(
4453
+ borderRadius: BorderRadius.circular(18),
4454
+ onTap: () async {
4455
+ final nextType = await _pickTaskTriggerType(
4456
+ context,
4457
+ selectedTriggerType,
4458
+ );
4459
+ if (nextType != null) {
4460
+ triggerType.value = nextType;
4461
+ }
4462
+ },
4463
+ child: InputDecorator(
4464
+ decoration: const InputDecoration(
4465
+ labelText: 'Trigger Type',
4466
+ ),
4467
+ child: Row(
4468
+ children: <Widget>[
4469
+ Container(
4470
+ width: 40,
4471
+ height: 40,
4472
+ decoration: BoxDecoration(
4473
+ color: _accent.withValues(alpha: 0.12),
4474
+ borderRadius: BorderRadius.circular(14),
4475
+ ),
4476
+ child: Icon(option.icon, color: _accent),
4477
+ ),
4478
+ const SizedBox(width: 12),
4479
+ Expanded(
4480
+ child: Column(
4481
+ crossAxisAlignment:
4482
+ CrossAxisAlignment.start,
4483
+ mainAxisSize: MainAxisSize.min,
4484
+ children: <Widget>[
4485
+ Text(
4486
+ option.label,
4487
+ style: TextStyle(
4488
+ fontWeight: FontWeight.w700,
4489
+ ),
4490
+ ),
4491
+ const SizedBox(height: 4),
4492
+ Text(
4493
+ option.description,
4494
+ style: TextStyle(
4495
+ color: _textSecondary,
4496
+ fontSize: 12.5,
4497
+ height: 1.35,
4498
+ ),
4499
+ ),
4500
+ ],
4501
+ ),
4502
+ ),
4503
+ const SizedBox(width: 12),
4504
+ Column(
4505
+ crossAxisAlignment: CrossAxisAlignment.end,
4506
+ mainAxisSize: MainAxisSize.min,
4507
+ children: <Widget>[
4508
+ Container(
4509
+ padding: const EdgeInsets.symmetric(
4510
+ horizontal: 10,
4511
+ vertical: 5,
4512
+ ),
4513
+ decoration: BoxDecoration(
4514
+ color: _bgCard.withValues(
4515
+ alpha: 0.72,
4516
+ ),
4517
+ borderRadius: BorderRadius.circular(
4518
+ 999,
4519
+ ),
4520
+ ),
4521
+ child: Text(
4522
+ option.section,
4523
+ style: TextStyle(
4524
+ color: _textSecondary,
4525
+ fontSize: 11,
4526
+ fontWeight: FontWeight.w700,
4527
+ ),
4528
+ ),
4529
+ ),
4530
+ const SizedBox(height: 8),
4531
+ Icon(
4532
+ Icons.unfold_more_rounded,
4533
+ color: _textSecondary,
4534
+ ),
4535
+ ],
4536
+ ),
4537
+ ],
4538
+ ),
4539
+ ),
4540
+ );
4541
+ },
4542
+ ),
4543
+ const SizedBox(height: 12),
4544
+ ValueListenableBuilder<String>(
4545
+ valueListenable: triggerType,
4546
+ builder: (context, selectedTriggerType, _) {
4547
+ if (selectedTriggerType == 'manual') {
4548
+ return Align(
4549
+ alignment: Alignment.centerLeft,
4550
+ child: Text(
4551
+ 'This task will only run when you press Run Now.',
4552
+ style: TextStyle(color: _textSecondary),
4553
+ ),
4554
+ );
4555
+ }
4556
+ if (selectedTriggerType == 'schedule') {
4557
+ return Column(
4558
+ children: <Widget>[
4559
+ TextField(
4560
+ controller: cronController,
4561
+ decoration: const InputDecoration(
4562
+ labelText: 'Cron Expression',
4563
+ helperText:
4564
+ 'Use cron for recurring tasks. Leave Run At empty for recurring schedules.',
4565
+ ),
4566
+ ),
4567
+ const SizedBox(height: 12),
4568
+ TextField(
4569
+ controller: runAtController,
4570
+ decoration: const InputDecoration(
4571
+ labelText: 'Run At (optional ISO datetime)',
4572
+ ),
4573
+ ),
4574
+ ],
4575
+ );
4576
+ }
4577
+
4578
+ return Column(
4579
+ children: <Widget>[
4580
+ TextField(
4581
+ controller: connectionIdController,
4582
+ decoration: const InputDecoration(
4583
+ labelText:
4584
+ 'Official Integration Connection ID',
4585
+ ),
4586
+ ),
4587
+ const SizedBox(height: 12),
4588
+ if (selectedTriggerType ==
4589
+ 'weather_event') ...<Widget>[
4590
+ TextField(
4591
+ controller: queryController,
4592
+ decoration: const InputDecoration(
4593
+ labelText: 'Location (city or place)',
4594
+ helperText: 'Required. Example: Berlin, DE',
4595
+ ),
4596
+ ),
4597
+ const SizedBox(height: 12),
4598
+ TextField(
4599
+ controller: weatherEventTypesController,
4600
+ decoration: const InputDecoration(
4601
+ labelText: 'Event Types (comma separated)',
4602
+ helperText:
4603
+ 'Supported: rain_start, snow_start, wind_alert, temperature_above, temperature_below',
4604
+ ),
4605
+ ),
4606
+ ],
4607
+ if (selectedTriggerType ==
4608
+ 'gmail_message_received' ||
4609
+ selectedTriggerType ==
4610
+ 'outlook_email_received') ...<Widget>[
4611
+ TextField(
4612
+ controller: queryController,
4613
+ decoration: const InputDecoration(
4614
+ labelText: 'Query / Filter',
4615
+ ),
4616
+ ),
4617
+ const SizedBox(height: 12),
4618
+ SwitchListTile(
4619
+ value: unreadOnly,
4620
+ contentPadding: EdgeInsets.zero,
4621
+ title: const Text('Unread Only'),
4622
+ onChanged: (value) =>
4623
+ setLocalState(() => unreadOnly = value),
4624
+ ),
4625
+ ],
4626
+ if (selectedTriggerType ==
4627
+ 'outlook_email_received') ...<Widget>[
4628
+ TextField(
4629
+ controller: channelController,
4630
+ decoration: const InputDecoration(
4631
+ labelText: 'Folder ID (optional)',
4632
+ ),
4633
+ ),
4634
+ const SizedBox(height: 12),
4635
+ ],
4636
+ if (selectedTriggerType ==
4637
+ 'slack_message_received' ||
4638
+ selectedTriggerType ==
4639
+ 'teams_message_received' ||
4640
+ selectedTriggerType ==
4641
+ 'whatsapp_personal_message_received') ...<
4642
+ Widget
4643
+ >[
4644
+ TextField(
4645
+ controller: channelController,
4646
+ decoration: InputDecoration(
4647
+ labelText:
4648
+ selectedTriggerType ==
4649
+ 'slack_message_received'
4650
+ ? 'Channel ID'
4651
+ : 'Chat ID',
4652
+ ),
4653
+ ),
4654
+ const SizedBox(height: 12),
4655
+ TextField(
4656
+ controller: senderController,
4657
+ decoration: const InputDecoration(
4658
+ labelText: 'Sender Filter (optional)',
4659
+ ),
4660
+ ),
4661
+ ],
4662
+ if (selectedTriggerType ==
4663
+ 'whatsapp_personal_message_received') ...<
4664
+ Widget
4665
+ >[
4666
+ const SizedBox(height: 12),
4667
+ SwitchListTile(
4668
+ value: ignoreGroups,
4669
+ contentPadding: EdgeInsets.zero,
4670
+ title: const Text('Ignore Groups'),
4671
+ onChanged: (value) =>
4672
+ setLocalState(() => ignoreGroups = value),
4673
+ ),
4674
+ ],
4675
+ ],
4676
+ );
4677
+ },
4678
+ ),
4679
+ const SizedBox(height: 12),
4680
+ TextField(
4681
+ controller: promptController,
4682
+ minLines: 5,
4683
+ maxLines: 10,
4684
+ decoration: const InputDecoration(labelText: 'Prompt'),
4685
+ ),
4686
+ const SizedBox(height: 12),
4687
+ DropdownButtonFormField<String>(
4688
+ initialValue: selectedModel,
4689
+ decoration: const InputDecoration(
4690
+ labelText: 'Model Override',
4691
+ ),
4692
+ items: <DropdownMenuItem<String>>[
4693
+ const DropdownMenuItem<String>(
4694
+ value: 'auto',
4695
+ child: Text('Auto (default routing)'),
4696
+ ),
4697
+ ...controller.supportedModels.map(
4698
+ (model) => DropdownMenuItem<String>(
4699
+ value: model.id,
4700
+ child: Text(model.label),
4701
+ ),
4702
+ ),
4703
+ ],
4704
+ onChanged: (value) => setLocalState(
4705
+ () => selectedModel = value ?? 'auto',
4706
+ ),
4707
+ ),
4708
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
4709
+ const SizedBox(height: 12),
4710
+ DropdownButtonFormField<String>(
4711
+ initialValue: selectedAgentId,
4712
+ isExpanded: true,
4713
+ decoration: const InputDecoration(
4714
+ labelText: 'Assigned Agent',
4715
+ ),
4716
+ items: controller.agentProfiles
4717
+ .map(
4718
+ (agent) => DropdownMenuItem<String>(
4719
+ value: agent.id,
4720
+ child: Text(agent.label),
4721
+ ),
4722
+ )
4723
+ .toList(),
4724
+ onChanged: (value) =>
4725
+ setLocalState(() => selectedAgentId = value),
4726
+ ),
4727
+ ],
4728
+ const SizedBox(height: 12),
4729
+ SwitchListTile(
4730
+ value: enabled,
4731
+ contentPadding: EdgeInsets.zero,
4732
+ title: Text('Enabled'),
4733
+ onChanged: (value) =>
4734
+ setLocalState(() => enabled = value),
4735
+ ),
4736
+ ],
4737
+ ),
4738
+ ),
4739
+ ),
4740
+ actions: <Widget>[
4741
+ TextButton(
4742
+ onPressed: () => Navigator.of(context).pop(),
4743
+ child: Text('Cancel'),
4744
+ ),
4745
+ FilledButton(
4746
+ onPressed: () async {
4747
+ final selectedTriggerType = triggerType.value;
4748
+ final triggerConfig = <String, dynamic>{};
4749
+ if (selectedTriggerType == 'manual') {
4750
+ // Manual trigger uses no trigger-specific config.
4751
+ } else if (selectedTriggerType == 'schedule') {
4752
+ final runAt = runAtController.text.trim();
4753
+ triggerConfig['mode'] = runAt.isEmpty
4754
+ ? 'recurring'
4755
+ : 'one_time';
4756
+ if (runAt.isEmpty) {
4757
+ triggerConfig['cronExpression'] = cronController.text
4758
+ .trim();
4759
+ } else {
4760
+ triggerConfig['runAt'] = runAt;
4761
+ }
4762
+ } else {
4763
+ final parsedConnectionId = int.tryParse(
4764
+ connectionIdController.text.trim(),
4765
+ );
4766
+ if (parsedConnectionId == null ||
4767
+ parsedConnectionId <= 0) {
4768
+ ScaffoldMessenger.of(context).showSnackBar(
4769
+ const SnackBar(
4770
+ content: Text(
4771
+ 'Connection ID must be a positive integer.',
4772
+ ),
4773
+ backgroundColor: Colors.red,
4774
+ ),
4775
+ );
4776
+ return;
4777
+ }
4778
+ triggerConfig['connectionId'] = parsedConnectionId;
4779
+ if (selectedTriggerType == 'weather_event') {
4780
+ if (queryController.text.trim().isEmpty) {
4781
+ ScaffoldMessenger.of(context).showSnackBar(
4782
+ const SnackBar(
4783
+ content: Text(
4784
+ 'Location is required for weather event triggers',
4785
+ ),
4786
+ backgroundColor: Colors.red,
4787
+ ),
4788
+ );
4789
+ return;
4790
+ }
4791
+ triggerConfig['location'] = queryController.text.trim();
4792
+ final eventTypes = weatherEventTypesController.text
4793
+ .split(',')
4794
+ .map((entry) => entry.trim())
4795
+ .where((entry) => entry.isNotEmpty)
4796
+ .toList();
4797
+ triggerConfig['eventTypes'] = eventTypes;
4798
+ }
4799
+ if (selectedTriggerType == 'gmail_message_received' ||
4800
+ selectedTriggerType == 'outlook_email_received') {
4801
+ if (queryController.text.trim().isNotEmpty) {
4802
+ triggerConfig['query'] = queryController.text.trim();
4803
+ }
4804
+ triggerConfig['unreadOnly'] = unreadOnly;
4805
+ if (selectedTriggerType == 'outlook_email_received' &&
4806
+ channelController.text.trim().isNotEmpty) {
4807
+ triggerConfig['folderId'] = channelController.text
4808
+ .trim();
4809
+ }
4810
+ }
4811
+ if (selectedTriggerType == 'slack_message_received') {
4812
+ triggerConfig['channel'] = channelController.text
4813
+ .trim();
4814
+ }
4815
+ if (selectedTriggerType == 'teams_message_received' ||
4816
+ selectedTriggerType ==
4817
+ 'whatsapp_personal_message_received') {
4818
+ triggerConfig['chatId'] = channelController.text.trim();
4819
+ }
4820
+ if (senderController.text.trim().isNotEmpty) {
4821
+ triggerConfig['sender'] = senderController.text.trim();
4822
+ }
4823
+ if (selectedTriggerType ==
4824
+ 'whatsapp_personal_message_received') {
4825
+ triggerConfig['ignoreGroups'] = ignoreGroups;
4826
+ }
4827
+ }
4828
+ await controller.saveTask(
4829
+ id: task?.id,
4830
+ name: nameController.text.trim(),
4831
+ triggerType: selectedTriggerType,
4832
+ triggerConfig: triggerConfig,
4833
+ prompt: promptController.text.trim(),
4834
+ model: selectedModel == 'auto' ? null : selectedModel,
4835
+ enabled: enabled,
4836
+ agentId: selectedAgentId,
4837
+ );
4838
+ if (context.mounted) {
4839
+ Navigator.of(context).pop();
4840
+ }
4841
+ },
4842
+ child: Text('Save'),
4843
+ ),
4844
+ ],
4845
+ );
4846
+ },
4847
+ );
4848
+ },
4849
+ );
4850
+ }
4851
+ }