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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.env.example +39 -0
  2. package/README.md +2 -0
  3. package/docs/capabilities.md +2 -2
  4. package/docs/configuration.md +13 -5
  5. package/docs/integrations.md +4 -1
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +109 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  30. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  31. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  32. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  33. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  35. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  36. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  37. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  38. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  39. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  43. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  44. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  45. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  46. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  48. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  49. package/flutter_app/android/build.gradle.kts +24 -0
  50. package/flutter_app/android/ci-release.keystore +0 -0
  51. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  52. package/flutter_app/android/gradle.properties +3 -0
  53. package/flutter_app/android/key.properties +4 -0
  54. package/flutter_app/android/settings.gradle.kts +26 -0
  55. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  57. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  58. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  59. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  60. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  62. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  63. package/flutter_app/lib/features/location/location_service.dart +119 -0
  64. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  65. package/flutter_app/lib/main.dart +23057 -0
  66. package/flutter_app/lib/main_app_shell.dart +1682 -0
  67. package/flutter_app/lib/main_integrations.dart +931 -0
  68. package/flutter_app/lib/main_launcher.dart +959 -0
  69. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  70. package/flutter_app/lib/main_models.dart +3473 -0
  71. package/flutter_app/lib/main_shared.dart +2861 -0
  72. package/flutter_app/lib/main_theme.dart +204 -0
  73. package/flutter_app/lib/main_voice_assistant.dart +831 -0
  74. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  75. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  76. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  77. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  78. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  79. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  80. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  81. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  82. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  83. package/flutter_app/lib/src/backend_client.dart +1833 -0
  84. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  85. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  86. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  87. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  88. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  89. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  90. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  91. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  92. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  93. package/flutter_app/lib/src/health_bridge.dart +136 -0
  94. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  95. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  96. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  97. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  98. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  99. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  100. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  101. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  102. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  103. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  104. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  105. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  106. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  107. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  108. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  109. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  110. package/flutter_app/lib/src/theme/palette.dart +81 -0
  111. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  112. package/flutter_app/linux/CMakeLists.txt +128 -0
  113. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  114. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  115. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  116. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  117. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  118. package/flutter_app/linux/runner/main.cc +6 -0
  119. package/flutter_app/linux/runner/my_application.cc +144 -0
  120. package/flutter_app/linux/runner/my_application.h +18 -0
  121. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  122. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  123. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  124. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  125. package/flutter_app/macos/Podfile +42 -0
  126. package/flutter_app/macos/Podfile.lock +87 -0
  127. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  128. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  129. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  130. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  131. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  132. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  133. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  134. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  135. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  136. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  137. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  138. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  139. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  140. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  141. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  142. package/flutter_app/macos/Runner/Info.plist +36 -0
  143. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  144. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  145. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  146. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  147. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  148. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  149. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  150. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  151. package/flutter_app/patch_strings.py +12 -0
  152. package/flutter_app/pubspec.lock +1088 -0
  153. package/flutter_app/pubspec.yaml +53 -0
  154. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  155. package/flutter_app/test/recording_payloads_test.dart +53 -0
  156. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  157. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  158. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  159. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  160. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  161. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  162. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  163. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  164. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  165. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  166. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  167. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  168. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  169. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  170. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  171. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  172. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  173. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  174. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  175. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  176. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  177. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  178. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  179. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  180. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  181. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  182. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  183. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  184. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  185. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  186. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  187. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  188. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  189. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  190. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  191. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  192. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  193. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  194. package/flutter_app/web/favicon.png +0 -0
  195. package/flutter_app/web/favicon.svg +12 -0
  196. package/flutter_app/web/icons/Icon-192.png +0 -0
  197. package/flutter_app/web/icons/Icon-512.png +0 -0
  198. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  199. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  200. package/flutter_app/web/index.html +39 -0
  201. package/flutter_app/web/manifest.json +35 -0
  202. package/flutter_app/windows/CMakeLists.txt +108 -0
  203. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  204. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  205. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  206. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  207. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  208. package/flutter_app/windows/runner/Runner.rc +121 -0
  209. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  210. package/flutter_app/windows/runner/flutter_window.h +37 -0
  211. package/flutter_app/windows/runner/main.cpp +53 -0
  212. package/flutter_app/windows/runner/resource.h +16 -0
  213. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  214. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  215. package/flutter_app/windows/runner/utils.cpp +65 -0
  216. package/flutter_app/windows/runner/utils.h +19 -0
  217. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  218. package/flutter_app/windows/runner/win32_window.h +102 -0
  219. package/lib/manager.js +231 -7
  220. package/package.json +3 -1
  221. package/server/db/database.js +68 -0
  222. package/server/http/middleware.js +50 -0
  223. package/server/http/routes.js +3 -1
  224. package/server/index.js +1 -0
  225. package/server/public/.last_build_id +1 -1
  226. package/server/public/assets/NOTICES +61 -0
  227. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  228. package/server/public/flutter_bootstrap.js +1 -1
  229. package/server/public/main.dart.js +65262 -64422
  230. package/server/routes/integrations.js +86 -0
  231. package/server/routes/memory.js +11 -2
  232. package/server/routes/screenHistory.js +46 -0
  233. package/server/routes/triggers.js +81 -0
  234. package/server/services/ai/models.js +30 -0
  235. package/server/services/ai/providers/githubCopilot.js +97 -0
  236. package/server/services/ai/providers/openai.js +2 -1
  237. package/server/services/ai/providers/openaiCodex.js +31 -0
  238. package/server/services/ai/settings.js +20 -0
  239. package/server/services/ai/systemPrompt.js +1 -1
  240. package/server/services/ai/tools.js +35 -6
  241. package/server/services/browser/controller.js +47 -3
  242. package/server/services/desktop/screenRecorder.js +172 -0
  243. package/server/services/integrations/env.js +5 -0
  244. package/server/services/integrations/github/common.js +106 -0
  245. package/server/services/integrations/github/provider.js +499 -0
  246. package/server/services/integrations/github/repos.js +1124 -0
  247. package/server/services/integrations/home_assistant/provider.js +306 -26
  248. package/server/services/integrations/manager.js +63 -7
  249. package/server/services/integrations/oauth_provider.js +13 -6
  250. package/server/services/integrations/provider_config_store.js +76 -0
  251. package/server/services/integrations/registry.js +4 -0
  252. package/server/services/integrations/trello/provider.js +744 -0
  253. package/server/services/integrations/whatsapp/provider.js +6 -2
  254. package/server/services/manager.js +22 -0
  255. package/server/services/memory/manager.js +39 -2
  256. package/server/services/skills/base_catalog.js +33 -0
  257. package/server/services/tasks/adapters/index.js +1 -0
  258. package/server/services/tasks/adapters/manual.js +12 -0
  259. package/server/services/tasks/runtime.js +1 -1
  260. package/server/services/voice/openaiClient.js +4 -1
  261. package/server/services/voice/providers.js +2 -1
  262. package/server/services/widgets/service.js +49 -4
  263. package/server/utils/local_secrets.js +56 -0
  264. package/server/utils/logger.js +37 -9
@@ -0,0 +1,3473 @@
1
+ part of 'main.dart';
2
+
3
+ const List<MessagingPlatformDescriptor>
4
+ messagingPlatforms = <MessagingPlatformDescriptor>[
5
+ MessagingPlatformDescriptor(
6
+ id: 'whatsapp',
7
+ label: 'WhatsApp',
8
+ subtitle: 'QR-based phone linking',
9
+ accent: Color(0xFF25D366),
10
+ connectMethod: MessagingConnectMethod.qr,
11
+ icon: Icons.chat_bubble,
12
+ ),
13
+ MessagingPlatformDescriptor(
14
+ id: 'telegram',
15
+ label: 'Telegram',
16
+ subtitle: 'Bot token and approved chats',
17
+ accent: Color(0xFF2AABEE),
18
+ connectMethod: MessagingConnectMethod.config,
19
+ icon: Icons.send_rounded,
20
+ configFields: <MessagingConfigField>[
21
+ MessagingConfigField(key: 'botToken', label: 'Bot Token', obscure: true),
22
+ ],
23
+ ),
24
+ MessagingPlatformDescriptor(
25
+ id: 'discord',
26
+ label: 'Discord',
27
+ subtitle: 'Bot token and server/channel access',
28
+ accent: Color(0xFF5865F2),
29
+ connectMethod: MessagingConnectMethod.config,
30
+ icon: Icons.sports_esports_rounded,
31
+ configFields: <MessagingConfigField>[
32
+ MessagingConfigField(key: 'token', label: 'Bot Token', obscure: true),
33
+ ],
34
+ ),
35
+ MessagingPlatformDescriptor(
36
+ id: 'slack',
37
+ label: 'Slack',
38
+ subtitle: 'Bot token, Events API, and channel access',
39
+ accent: Color(0xFF36C5F0),
40
+ connectMethod: MessagingConnectMethod.config,
41
+ icon: Icons.tag_rounded,
42
+ configFields: <MessagingConfigField>[
43
+ MessagingConfigField(key: 'botToken', label: 'Bot Token', obscure: true),
44
+ MessagingConfigField(
45
+ key: 'signingSecret',
46
+ label: 'Signing Secret',
47
+ obscure: true,
48
+ ),
49
+ MessagingConfigField(
50
+ key: 'inboundSecret',
51
+ label: 'Inbound Secret',
52
+ obscure: true,
53
+ ),
54
+ ],
55
+ ),
56
+ MessagingPlatformDescriptor(
57
+ id: 'google_chat',
58
+ label: 'Google Chat',
59
+ subtitle: 'Space webhook and app callback support',
60
+ accent: Color(0xFF34A853),
61
+ connectMethod: MessagingConnectMethod.config,
62
+ icon: Icons.forum_rounded,
63
+ configFields: <MessagingConfigField>[
64
+ MessagingConfigField(
65
+ key: 'webhookUrl',
66
+ label: 'Webhook URL',
67
+ obscure: true,
68
+ ),
69
+ MessagingConfigField(
70
+ key: 'inboundSecret',
71
+ label: 'Inbound Secret',
72
+ obscure: true,
73
+ ),
74
+ MessagingConfigField(key: 'defaultTo', label: 'Default Space / Chat ID'),
75
+ ],
76
+ ),
77
+ MessagingPlatformDescriptor(
78
+ id: 'teams',
79
+ label: 'Microsoft Teams',
80
+ subtitle: 'Incoming webhook and outgoing callback support',
81
+ accent: Color(0xFF6264A7),
82
+ connectMethod: MessagingConnectMethod.config,
83
+ icon: Icons.groups_rounded,
84
+ configFields: <MessagingConfigField>[
85
+ MessagingConfigField(
86
+ key: 'webhookUrl',
87
+ label: 'Webhook URL',
88
+ obscure: true,
89
+ ),
90
+ MessagingConfigField(
91
+ key: 'inboundSecret',
92
+ label: 'Inbound Secret',
93
+ obscure: true,
94
+ ),
95
+ MessagingConfigField(key: 'defaultTo', label: 'Default Conversation ID'),
96
+ ],
97
+ ),
98
+ MessagingPlatformDescriptor(
99
+ id: 'matrix',
100
+ label: 'Matrix',
101
+ subtitle: 'Homeserver token with room polling',
102
+ accent: Color(0xFF0DBD8B),
103
+ connectMethod: MessagingConnectMethod.config,
104
+ icon: Icons.grid_view_rounded,
105
+ configFields: <MessagingConfigField>[
106
+ MessagingConfigField(key: 'homeserver', label: 'Homeserver URL'),
107
+ MessagingConfigField(
108
+ key: 'accessToken',
109
+ label: 'Access Token',
110
+ obscure: true,
111
+ ),
112
+ MessagingConfigField(key: 'userId', label: 'User ID'),
113
+ MessagingConfigField(
114
+ key: 'pollIntervalMs',
115
+ label: 'Poll Interval ms',
116
+ defaultValue: '5000',
117
+ ),
118
+ ],
119
+ ),
120
+ MessagingPlatformDescriptor(
121
+ id: 'signal',
122
+ label: 'Signal',
123
+ subtitle: 'signal-cli REST API bridge',
124
+ accent: Color(0xFF3A76F0),
125
+ connectMethod: MessagingConnectMethod.config,
126
+ icon: Icons.lock_rounded,
127
+ configFields: <MessagingConfigField>[
128
+ MessagingConfigField(key: 'restUrl', label: 'signal-cli REST API URL'),
129
+ MessagingConfigField(key: 'account', label: 'Account Number'),
130
+ MessagingConfigField(
131
+ key: 'pollEnabled',
132
+ label: 'Enable receive polling',
133
+ kind: MessagingConfigFieldKind.boolean,
134
+ ),
135
+ MessagingConfigField(
136
+ key: 'pollIntervalMs',
137
+ label: 'Poll Interval ms',
138
+ defaultValue: '10000',
139
+ ),
140
+ ],
141
+ ),
142
+ MessagingPlatformDescriptor(
143
+ id: 'imessage',
144
+ label: 'iMessage',
145
+ subtitle: 'BlueBubbles-compatible bridge',
146
+ accent: Color(0xFF007AFF),
147
+ connectMethod: MessagingConnectMethod.config,
148
+ icon: Icons.sms_rounded,
149
+ configFields: <MessagingConfigField>[
150
+ MessagingConfigField(key: 'serverUrl', label: 'BlueBubbles Server URL'),
151
+ MessagingConfigField(
152
+ key: 'password',
153
+ label: 'Password / API Key',
154
+ obscure: true,
155
+ ),
156
+ MessagingConfigField(
157
+ key: 'sendPath',
158
+ label: 'Send Path',
159
+ defaultValue: '/api/v1/message/text',
160
+ ),
161
+ MessagingConfigField(
162
+ key: 'inboundSecret',
163
+ label: 'Inbound Secret',
164
+ obscure: true,
165
+ ),
166
+ ],
167
+ ),
168
+ MessagingPlatformDescriptor(
169
+ id: 'bluebubbles',
170
+ label: 'BlueBubbles',
171
+ subtitle: 'Direct BlueBubbles iMessage bridge',
172
+ accent: Color(0xFF0A84FF),
173
+ connectMethod: MessagingConnectMethod.config,
174
+ icon: Icons.bubble_chart_rounded,
175
+ configFields: <MessagingConfigField>[
176
+ MessagingConfigField(key: 'serverUrl', label: 'BlueBubbles Server URL'),
177
+ MessagingConfigField(
178
+ key: 'password',
179
+ label: 'Password / API Key',
180
+ obscure: true,
181
+ ),
182
+ MessagingConfigField(
183
+ key: 'sendPath',
184
+ label: 'Send Path',
185
+ defaultValue: '/api/v1/message/text',
186
+ ),
187
+ MessagingConfigField(
188
+ key: 'inboundSecret',
189
+ label: 'Inbound Secret',
190
+ obscure: true,
191
+ ),
192
+ ],
193
+ ),
194
+ MessagingPlatformDescriptor(
195
+ id: 'irc',
196
+ label: 'IRC',
197
+ subtitle: 'Server, nick, channel, and optional TLS',
198
+ accent: Color(0xFF7E57C2),
199
+ connectMethod: MessagingConnectMethod.config,
200
+ icon: Icons.terminal_rounded,
201
+ configFields: <MessagingConfigField>[
202
+ MessagingConfigField(key: 'server', label: 'Server'),
203
+ MessagingConfigField(key: 'port', label: 'Port', defaultValue: '6667'),
204
+ MessagingConfigField(key: 'nick', label: 'Nickname'),
205
+ MessagingConfigField(key: 'password', label: 'Password', obscure: true),
206
+ MessagingConfigField(key: 'channels', label: 'Channels, comma-separated'),
207
+ MessagingConfigField(
208
+ key: 'tls',
209
+ label: 'Use TLS',
210
+ kind: MessagingConfigFieldKind.boolean,
211
+ ),
212
+ ],
213
+ ),
214
+ MessagingPlatformDescriptor(
215
+ id: 'twitch',
216
+ label: 'Twitch',
217
+ subtitle: 'Twitch chat over IRC',
218
+ accent: Color(0xFF9146FF),
219
+ connectMethod: MessagingConnectMethod.config,
220
+ icon: Icons.live_tv_rounded,
221
+ configFields: <MessagingConfigField>[
222
+ MessagingConfigField(key: 'nick', label: 'Bot Username'),
223
+ MessagingConfigField(
224
+ key: 'oauthToken',
225
+ label: 'OAuth Token',
226
+ obscure: true,
227
+ ),
228
+ MessagingConfigField(key: 'channels', label: 'Channels, comma-separated'),
229
+ ],
230
+ ),
231
+ MessagingPlatformDescriptor(
232
+ id: 'line',
233
+ label: 'LINE',
234
+ subtitle: 'Messaging API push and webhook events',
235
+ accent: Color(0xFF06C755),
236
+ connectMethod: MessagingConnectMethod.config,
237
+ icon: Icons.chat_rounded,
238
+ configFields: <MessagingConfigField>[
239
+ MessagingConfigField(
240
+ key: 'channelAccessToken',
241
+ label: 'Channel Access Token',
242
+ obscure: true,
243
+ ),
244
+ MessagingConfigField(
245
+ key: 'inboundSecret',
246
+ label: 'Inbound Secret',
247
+ obscure: true,
248
+ ),
249
+ ],
250
+ ),
251
+ MessagingPlatformDescriptor(
252
+ id: 'mattermost',
253
+ label: 'Mattermost',
254
+ subtitle: 'Webhook or REST channel posting',
255
+ accent: Color(0xFF0058CC),
256
+ connectMethod: MessagingConnectMethod.config,
257
+ icon: Icons.forum_outlined,
258
+ configFields: <MessagingConfigField>[
259
+ MessagingConfigField(
260
+ key: 'webhookUrl',
261
+ label: 'Webhook URL',
262
+ obscure: true,
263
+ ),
264
+ MessagingConfigField(key: 'baseUrl', label: 'Base URL'),
265
+ MessagingConfigField(key: 'token', label: 'Access Token', obscure: true),
266
+ MessagingConfigField(
267
+ key: 'inboundSecret',
268
+ label: 'Inbound Secret',
269
+ obscure: true,
270
+ ),
271
+ ],
272
+ ),
273
+ MessagingPlatformDescriptor(
274
+ id: 'telnyx',
275
+ label: 'Telnyx Voice',
276
+ subtitle: 'Inbound and outbound calling',
277
+ accent: Color(0xFF00C8A0),
278
+ connectMethod: MessagingConnectMethod.config,
279
+ icon: Icons.call_rounded,
280
+ ),
281
+ ...longTailMessagingPlatforms,
282
+ ];
283
+
284
+ const List<MessagingPlatformDescriptor> longTailMessagingPlatforms =
285
+ <MessagingPlatformDescriptor>[
286
+ MessagingPlatformDescriptor(
287
+ id: 'feishu',
288
+ label: 'Feishu',
289
+ subtitle: 'Configurable webhook bridge',
290
+ accent: Color(0xFF3370FF),
291
+ connectMethod: MessagingConnectMethod.config,
292
+ icon: Icons.webhook_rounded,
293
+ configFields: genericWebhookConfigFields,
294
+ ),
295
+ MessagingPlatformDescriptor(
296
+ id: 'nextcloud_talk',
297
+ label: 'Nextcloud Talk',
298
+ subtitle: 'Configurable Talk webhook bridge',
299
+ accent: Color(0xFF0082C9),
300
+ connectMethod: MessagingConnectMethod.config,
301
+ icon: Icons.cloud_rounded,
302
+ configFields: genericWebhookConfigFields,
303
+ ),
304
+ MessagingPlatformDescriptor(
305
+ id: 'nostr',
306
+ label: 'Nostr',
307
+ subtitle: 'Configurable relay or webhook bridge',
308
+ accent: Color(0xFF9C27B0),
309
+ connectMethod: MessagingConnectMethod.config,
310
+ icon: Icons.hub_rounded,
311
+ configFields: genericWebhookConfigFields,
312
+ ),
313
+ MessagingPlatformDescriptor(
314
+ id: 'synology_chat',
315
+ label: 'Synology Chat',
316
+ subtitle: 'Configurable webhook bridge',
317
+ accent: Color(0xFF1E88E5),
318
+ connectMethod: MessagingConnectMethod.config,
319
+ icon: Icons.storage_rounded,
320
+ configFields: genericWebhookConfigFields,
321
+ ),
322
+ MessagingPlatformDescriptor(
323
+ id: 'tlon',
324
+ label: 'Tlon',
325
+ subtitle: 'Configurable webhook bridge',
326
+ accent: Color(0xFF111111),
327
+ connectMethod: MessagingConnectMethod.config,
328
+ icon: Icons.blur_on_rounded,
329
+ configFields: genericWebhookConfigFields,
330
+ ),
331
+ MessagingPlatformDescriptor(
332
+ id: 'zalo',
333
+ label: 'Zalo',
334
+ subtitle: 'Configurable webhook bridge',
335
+ accent: Color(0xFF0068FF),
336
+ connectMethod: MessagingConnectMethod.config,
337
+ icon: Icons.message_rounded,
338
+ configFields: genericWebhookConfigFields,
339
+ ),
340
+ MessagingPlatformDescriptor(
341
+ id: 'zalo_personal',
342
+ label: 'Zalo Personal',
343
+ subtitle: 'Configurable personal webhook bridge',
344
+ accent: Color(0xFF0288D1),
345
+ connectMethod: MessagingConnectMethod.config,
346
+ icon: Icons.person_pin_circle_rounded,
347
+ configFields: genericWebhookConfigFields,
348
+ ),
349
+ MessagingPlatformDescriptor(
350
+ id: 'wechat',
351
+ label: 'WeChat',
352
+ subtitle: 'Configurable webhook bridge',
353
+ accent: Color(0xFF07C160),
354
+ connectMethod: MessagingConnectMethod.config,
355
+ icon: Icons.chat_bubble_outline_rounded,
356
+ configFields: genericWebhookConfigFields,
357
+ ),
358
+ MessagingPlatformDescriptor(
359
+ id: 'webchat',
360
+ label: 'WebChat',
361
+ subtitle: 'Configurable web inbox bridge',
362
+ accent: Color(0xFF00A1F1),
363
+ connectMethod: MessagingConnectMethod.config,
364
+ icon: Icons.public_rounded,
365
+ configFields: genericWebhookConfigFields,
366
+ ),
367
+ ];
368
+
369
+ const List<MessagingConfigField> genericWebhookConfigFields =
370
+ <MessagingConfigField>[
371
+ MessagingConfigField(
372
+ key: 'webhookUrl',
373
+ label: 'Outbound Webhook URL',
374
+ obscure: true,
375
+ ),
376
+ MessagingConfigField(
377
+ key: 'outboundUrl',
378
+ label: 'Custom Outbound URL',
379
+ obscure: true,
380
+ ),
381
+ MessagingConfigField(key: 'token', label: 'Access Token', obscure: true),
382
+ MessagingConfigField(
383
+ key: 'inboundSecret',
384
+ label: 'Inbound Secret',
385
+ obscure: true,
386
+ ),
387
+ MessagingConfigField(
388
+ key: 'contentField',
389
+ label: 'Content Field',
390
+ defaultValue: 'text',
391
+ ),
392
+ MessagingConfigField(key: 'recipientField', label: 'Recipient Field'),
393
+ MessagingConfigField(
394
+ key: 'headers',
395
+ label: 'Headers JSON',
396
+ kind: MessagingConfigFieldKind.multiline,
397
+ ),
398
+ MessagingConfigField(
399
+ key: 'bodyTemplate',
400
+ label: 'Body Template JSON',
401
+ kind: MessagingConfigFieldKind.multiline,
402
+ ),
403
+ ];
404
+
405
+ enum MessagingConnectMethod { qr, config }
406
+
407
+ enum MessagingConfigFieldKind { text, password, multiline, boolean }
408
+
409
+ class MessagingConfigField {
410
+ const MessagingConfigField({
411
+ required this.key,
412
+ required this.label,
413
+ this.kind = MessagingConfigFieldKind.text,
414
+ this.obscure = false,
415
+ this.defaultValue,
416
+ });
417
+
418
+ final String key;
419
+ final String label;
420
+ final MessagingConfigFieldKind kind;
421
+ final bool obscure;
422
+ final String? defaultValue;
423
+ }
424
+
425
+ class MessagingPlatformDescriptor {
426
+ const MessagingPlatformDescriptor({
427
+ required this.id,
428
+ required this.label,
429
+ required this.subtitle,
430
+ required this.accent,
431
+ required this.connectMethod,
432
+ required this.icon,
433
+ this.configFields = const <MessagingConfigField>[],
434
+ this.accessCapabilities,
435
+ });
436
+
437
+ final String id;
438
+ final String label;
439
+ final String subtitle;
440
+ final Color accent;
441
+ final MessagingConnectMethod connectMethod;
442
+ final IconData icon;
443
+ final List<MessagingConfigField> configFields;
444
+ final MessagingAccessCapabilities? accessCapabilities;
445
+
446
+ String get settingsKey => '${id}_config';
447
+ }
448
+
449
+ class MessagingPlatformGroup {
450
+ const MessagingPlatformGroup({
451
+ required this.label,
452
+ required this.subtitle,
453
+ required this.ids,
454
+ });
455
+
456
+ final String label;
457
+ final String subtitle;
458
+ final List<String> ids;
459
+ }
460
+
461
+ class MessagingPlatformStatus {
462
+ const MessagingPlatformStatus({
463
+ required this.platform,
464
+ required this.status,
465
+ this.lastConnected,
466
+ this.authInfo = const <String, dynamic>{},
467
+ });
468
+
469
+ factory MessagingPlatformStatus.fromJson(
470
+ String platform,
471
+ Map<String, dynamic> json,
472
+ ) {
473
+ return MessagingPlatformStatus(
474
+ platform: platform,
475
+ status:
476
+ json['status']?.toString().ifEmpty('not_configured') ??
477
+ 'not_configured',
478
+ lastConnected: _parseOptionalTimestamp(json['lastConnected']?.toString()),
479
+ authInfo: _jsonMap(json['authInfo']),
480
+ );
481
+ }
482
+
483
+ factory MessagingPlatformStatus.empty(String platform) {
484
+ return MessagingPlatformStatus(
485
+ platform: platform,
486
+ status: 'not_configured',
487
+ );
488
+ }
489
+
490
+ final String platform;
491
+ final String status;
492
+ final DateTime? lastConnected;
493
+ final Map<String, dynamic> authInfo;
494
+
495
+ bool get isConnected => status == 'connected';
496
+ bool get isConnecting => status == 'connecting' || status == 'awaiting_qr';
497
+
498
+ String get statusLabel => status.replaceAll('_', ' ');
499
+
500
+ String get authLabel {
501
+ for (final key in <String>['phoneNumber', 'tag', 'username', 'label']) {
502
+ final value = authInfo[key]?.toString();
503
+ if (value != null && value.trim().isNotEmpty) {
504
+ return key == 'username' ? '@$value' : value;
505
+ }
506
+ }
507
+ if (lastConnected != null) {
508
+ return 'Last seen ${_formatTimestamp(lastConnected!)}';
509
+ }
510
+ return 'Not connected';
511
+ }
512
+
513
+ Color get badgeColor {
514
+ switch (status) {
515
+ case 'connected':
516
+ return _success;
517
+ case 'awaiting_qr':
518
+ case 'connecting':
519
+ return _warning;
520
+ case 'logged_out':
521
+ return _danger;
522
+ default:
523
+ return _textSecondary;
524
+ }
525
+ }
526
+ }
527
+
528
+ class MessagingMessage {
529
+ const MessagingMessage({
530
+ required this.platform,
531
+ required this.content,
532
+ required this.createdAt,
533
+ required this.outgoing,
534
+ this.chatId,
535
+ this.sender,
536
+ this.senderName,
537
+ this.target,
538
+ });
539
+
540
+ factory MessagingMessage.fromJson(Map<dynamic, dynamic> json) {
541
+ final metadata = _jsonMap(_decodeMaybeJson(json['metadata']));
542
+ final sender = (metadata['sender']?.toString() ?? '').trim();
543
+ final senderName =
544
+ (metadata['senderName']?.toString() ??
545
+ metadata['sender_name']?.toString() ??
546
+ json['sender_name']?.toString() ??
547
+ '')
548
+ .trim();
549
+ final chatId =
550
+ (json['platform_chat_id']?.toString() ??
551
+ metadata['chatId']?.toString() ??
552
+ metadata['chat_id']?.toString() ??
553
+ '')
554
+ .trim();
555
+ return MessagingMessage(
556
+ platform: json['platform']?.toString() ?? 'web',
557
+ content: json['content']?.toString() ?? '',
558
+ createdAt: _parseTimestamp(json['created_at']?.toString()),
559
+ outgoing: json['role']?.toString() == 'assistant',
560
+ chatId: chatId.isEmpty ? null : chatId,
561
+ sender: sender.isEmpty ? null : sender,
562
+ senderName: senderName.isEmpty ? null : senderName,
563
+ target: chatId.isEmpty ? null : chatId,
564
+ );
565
+ }
566
+
567
+ factory MessagingMessage.fromSocket(
568
+ Map<String, dynamic> json, {
569
+ required bool outgoing,
570
+ }) {
571
+ return MessagingMessage(
572
+ platform: json['platform']?.toString() ?? 'web',
573
+ content: json['content']?.toString() ?? '',
574
+ createdAt: DateTime.now(),
575
+ outgoing: outgoing,
576
+ chatId: json['chatId']?.toString() ?? json['to']?.toString(),
577
+ sender: json['sender']?.toString(),
578
+ senderName: json['senderName']?.toString(),
579
+ target: json['to']?.toString(),
580
+ );
581
+ }
582
+
583
+ factory MessagingMessage.fromBlockedNotice(BlockedSenderNotice notice) {
584
+ final summary = <String>[
585
+ 'Blocked incoming message from ${notice.senderLabel}.',
586
+ if (notice.meta.isNotEmpty) notice.meta,
587
+ if (notice.suggestions.isNotEmpty)
588
+ 'Suggestions: ${notice.suggestions.map((item) => item.label).join(', ')}',
589
+ 'Update the access list to allow replies.',
590
+ ].join('\n');
591
+
592
+ return MessagingMessage(
593
+ platform: notice.platform,
594
+ content: summary,
595
+ createdAt: DateTime.now(),
596
+ outgoing: false,
597
+ chatId: notice.chatId,
598
+ sender: notice.sender,
599
+ senderName: notice.senderName,
600
+ );
601
+ }
602
+
603
+ final String platform;
604
+ final String content;
605
+ final DateTime createdAt;
606
+ final bool outgoing;
607
+ final String? chatId;
608
+ final String? sender;
609
+ final String? senderName;
610
+ final String? target;
611
+
612
+ String get createdAtLabel => _formatTimestamp(createdAt);
613
+
614
+ String get senderLabel {
615
+ if (outgoing) {
616
+ return target?.ifEmpty('Outgoing message') ?? 'Outgoing message';
617
+ }
618
+ return senderName?.ifEmpty(sender ?? platform.toUpperCase()) ??
619
+ sender?.ifEmpty(platform.toUpperCase()) ??
620
+ platform.toUpperCase();
621
+ }
622
+ }
623
+
624
+ class MessagingQrState {
625
+ const MessagingQrState({required this.platform, required this.qr});
626
+
627
+ final String platform;
628
+ final String qr;
629
+
630
+ String get platformLabel {
631
+ for (final item in messagingPlatforms) {
632
+ if (item.id == platform) {
633
+ return item.label;
634
+ }
635
+ }
636
+ return platform;
637
+ }
638
+ }
639
+
640
+ class MessagingAccessRule {
641
+ const MessagingAccessRule({
642
+ required this.scope,
643
+ required this.value,
644
+ this.label,
645
+ });
646
+
647
+ factory MessagingAccessRule.fromJson(Map<String, dynamic> json) {
648
+ return MessagingAccessRule(
649
+ scope: json['scope']?.toString() ?? 'chat',
650
+ value: json['value']?.toString() ?? '',
651
+ label: json['label']?.toString(),
652
+ );
653
+ }
654
+
655
+ final String scope;
656
+ final String value;
657
+ final String? label;
658
+
659
+ Map<String, dynamic> toJson() => <String, dynamic>{
660
+ 'scope': scope,
661
+ 'value': value,
662
+ if (label != null && label!.trim().isNotEmpty) 'label': label,
663
+ };
664
+
665
+ String get id => '$scope:$value';
666
+
667
+ String get displayLabel => label?.ifEmpty(value) ?? value;
668
+
669
+ String get scopeLabel {
670
+ switch (scope) {
671
+ case 'phone_number':
672
+ return 'Number';
673
+ case 'server':
674
+ return 'Server';
675
+ case 'channel':
676
+ return 'Channel';
677
+ case 'group':
678
+ return 'Group';
679
+ case 'room':
680
+ return 'Room';
681
+ case 'role':
682
+ return 'Role';
683
+ case 'dm':
684
+ return 'DM';
685
+ case 'user':
686
+ return 'User';
687
+ default:
688
+ return 'Chat';
689
+ }
690
+ }
691
+ }
692
+
693
+ class MessagingAccessPolicy {
694
+ const MessagingAccessPolicy({
695
+ required this.directPolicy,
696
+ required this.sharedPolicy,
697
+ required this.requireMentionInShared,
698
+ required this.directRules,
699
+ required this.sharedSpaceRules,
700
+ required this.sharedActorRules,
701
+ });
702
+
703
+ factory MessagingAccessPolicy.fromJson(Map<String, dynamic> json) {
704
+ return MessagingAccessPolicy(
705
+ directPolicy: json['directPolicy']?.toString().ifEmpty('allowlist') ?? 'allowlist',
706
+ sharedPolicy: json['sharedPolicy']?.toString().ifEmpty('allowlist') ?? 'allowlist',
707
+ requireMentionInShared: json['requireMentionInShared'] == true,
708
+ directRules: (json['directRules'] is List ? json['directRules'] as List : const <dynamic>[])
709
+ .whereType<Map>()
710
+ .map((item) => MessagingAccessRule.fromJson(Map<String, dynamic>.from(item)))
711
+ .toList(growable: false),
712
+ sharedSpaceRules: (json['sharedSpaceRules'] is List ? json['sharedSpaceRules'] as List : const <dynamic>[])
713
+ .whereType<Map>()
714
+ .map((item) => MessagingAccessRule.fromJson(Map<String, dynamic>.from(item)))
715
+ .toList(growable: false),
716
+ sharedActorRules: (json['sharedActorRules'] is List ? json['sharedActorRules'] as List : const <dynamic>[])
717
+ .whereType<Map>()
718
+ .map((item) => MessagingAccessRule.fromJson(Map<String, dynamic>.from(item)))
719
+ .toList(growable: false),
720
+ );
721
+ }
722
+
723
+ const MessagingAccessPolicy.defaults({
724
+ this.directPolicy = 'allowlist',
725
+ this.sharedPolicy = 'allowlist',
726
+ this.requireMentionInShared = true,
727
+ this.directRules = const <MessagingAccessRule>[],
728
+ this.sharedSpaceRules = const <MessagingAccessRule>[],
729
+ this.sharedActorRules = const <MessagingAccessRule>[],
730
+ });
731
+
732
+ final String directPolicy;
733
+ final String sharedPolicy;
734
+ final bool requireMentionInShared;
735
+ final List<MessagingAccessRule> directRules;
736
+ final List<MessagingAccessRule> sharedSpaceRules;
737
+ final List<MessagingAccessRule> sharedActorRules;
738
+
739
+ MessagingAccessPolicy copyWith({
740
+ String? directPolicy,
741
+ String? sharedPolicy,
742
+ bool? requireMentionInShared,
743
+ List<MessagingAccessRule>? directRules,
744
+ List<MessagingAccessRule>? sharedSpaceRules,
745
+ List<MessagingAccessRule>? sharedActorRules,
746
+ }) {
747
+ return MessagingAccessPolicy(
748
+ directPolicy: directPolicy ?? this.directPolicy,
749
+ sharedPolicy: sharedPolicy ?? this.sharedPolicy,
750
+ requireMentionInShared:
751
+ requireMentionInShared ?? this.requireMentionInShared,
752
+ directRules: directRules ?? this.directRules,
753
+ sharedSpaceRules: sharedSpaceRules ?? this.sharedSpaceRules,
754
+ sharedActorRules: sharedActorRules ?? this.sharedActorRules,
755
+ );
756
+ }
757
+
758
+ Map<String, dynamic> toJson() => <String, dynamic>{
759
+ 'directPolicy': directPolicy,
760
+ 'sharedPolicy': sharedPolicy,
761
+ 'requireMentionInShared': requireMentionInShared,
762
+ 'directRules': directRules.map((rule) => rule.toJson()).toList(growable: false),
763
+ 'sharedSpaceRules':
764
+ sharedSpaceRules.map((rule) => rule.toJson()).toList(growable: false),
765
+ 'sharedActorRules':
766
+ sharedActorRules.map((rule) => rule.toJson()).toList(growable: false),
767
+ };
768
+
769
+ int get totalRuleCount =>
770
+ directRules.length + sharedSpaceRules.length + sharedActorRules.length;
771
+ }
772
+
773
+ class MessagingAccessCapabilities {
774
+ const MessagingAccessCapabilities({
775
+ this.supportsDirectPolicy = true,
776
+ this.supportsSharedPolicy = true,
777
+ this.supportsMentionGate = false,
778
+ this.supportsDiscovery = false,
779
+ this.directRuleScopes = const <String>[],
780
+ this.sharedSpaceRuleScopes = const <String>[],
781
+ this.sharedActorRuleScopes = const <String>[],
782
+ this.manualEntryHint = '',
783
+ });
784
+
785
+ factory MessagingAccessCapabilities.fromJson(Map<String, dynamic> json) {
786
+ List<String> _stringList(dynamic value) {
787
+ if (value is! List) return const <String>[];
788
+ return value.map((item) => item.toString()).where((item) => item.isNotEmpty).toList(growable: false);
789
+ }
790
+
791
+ return MessagingAccessCapabilities(
792
+ supportsDirectPolicy: json['supportsDirectPolicy'] != false,
793
+ supportsSharedPolicy: json['supportsSharedPolicy'] != false,
794
+ supportsMentionGate: json['supportsMentionGate'] == true,
795
+ supportsDiscovery: json['supportsDiscovery'] == true,
796
+ directRuleScopes: _stringList(json['directRuleScopes']),
797
+ sharedSpaceRuleScopes: _stringList(json['sharedSpaceRuleScopes']),
798
+ sharedActorRuleScopes: _stringList(json['sharedActorRuleScopes']),
799
+ manualEntryHint: json['manualEntryHint']?.toString() ?? '',
800
+ );
801
+ }
802
+
803
+ final bool supportsDirectPolicy;
804
+ final bool supportsSharedPolicy;
805
+ final bool supportsMentionGate;
806
+ final bool supportsDiscovery;
807
+ final List<String> directRuleScopes;
808
+ final List<String> sharedSpaceRuleScopes;
809
+ final List<String> sharedActorRuleScopes;
810
+ final String manualEntryHint;
811
+
812
+ Map<String, dynamic> toJson() => <String, dynamic>{
813
+ 'supportsDirectPolicy': supportsDirectPolicy,
814
+ 'supportsSharedPolicy': supportsSharedPolicy,
815
+ 'supportsMentionGate': supportsMentionGate,
816
+ 'supportsDiscovery': supportsDiscovery,
817
+ 'directRuleScopes': directRuleScopes,
818
+ 'sharedSpaceRuleScopes': sharedSpaceRuleScopes,
819
+ 'sharedActorRuleScopes': sharedActorRuleScopes,
820
+ 'manualEntryHint': manualEntryHint,
821
+ };
822
+ }
823
+
824
+ class MessagingAccessTarget {
825
+ const MessagingAccessTarget({
826
+ required this.source,
827
+ required this.bucket,
828
+ required this.scope,
829
+ required this.value,
830
+ required this.label,
831
+ required this.subtitle,
832
+ });
833
+
834
+ factory MessagingAccessTarget.fromJson(Map<String, dynamic> json) {
835
+ return MessagingAccessTarget(
836
+ source: json['source']?.toString() ?? 'manual',
837
+ bucket: json['bucket']?.toString() ?? 'sharedSpaceRules',
838
+ scope: json['scope']?.toString() ?? 'chat',
839
+ value: json['value']?.toString() ?? '',
840
+ label: json['label']?.toString().ifEmpty(json['value']?.toString() ?? '') ??
841
+ (json['value']?.toString() ?? ''),
842
+ subtitle: json['subtitle']?.toString() ?? '',
843
+ );
844
+ }
845
+
846
+ final String source;
847
+ final String bucket;
848
+ final String scope;
849
+ final String value;
850
+ final String label;
851
+ final String subtitle;
852
+
853
+ MessagingAccessRule get asRule =>
854
+ MessagingAccessRule(scope: scope, value: value, label: label);
855
+
856
+ String get id => '$bucket:$scope:$value';
857
+
858
+ Map<String, dynamic> toJson() => <String, dynamic>{
859
+ 'source': source,
860
+ 'bucket': bucket,
861
+ 'scope': scope,
862
+ 'value': value,
863
+ 'label': label,
864
+ 'subtitle': subtitle,
865
+ };
866
+ }
867
+
868
+ class MessagingAccessCatalog {
869
+ const MessagingAccessCatalog({
870
+ required this.platform,
871
+ required this.policy,
872
+ required this.capabilities,
873
+ required this.discoveredTargets,
874
+ required this.suggestedTargets,
875
+ required this.summary,
876
+ });
877
+
878
+ factory MessagingAccessCatalog.fromJson(
879
+ String platform,
880
+ Map<String, dynamic> json,
881
+ ) {
882
+ List<MessagingAccessTarget> _targets(dynamic raw) {
883
+ if (raw is! List) return const <MessagingAccessTarget>[];
884
+ return raw
885
+ .whereType<Map>()
886
+ .map((item) => MessagingAccessTarget.fromJson(Map<String, dynamic>.from(item)))
887
+ .where((item) => item.value.isNotEmpty)
888
+ .toList(growable: false);
889
+ }
890
+
891
+ return MessagingAccessCatalog(
892
+ platform: platform,
893
+ policy: MessagingAccessPolicy.fromJson(_jsonMap(json['policy'])),
894
+ capabilities: MessagingAccessCapabilities.fromJson(
895
+ _jsonMap(json['capabilities']),
896
+ ),
897
+ discoveredTargets: _targets(json['discoveredTargets']),
898
+ suggestedTargets: _targets(json['suggestedTargets']),
899
+ summary: json['summary']?.toString() ?? 'Access policy',
900
+ );
901
+ }
902
+
903
+ factory MessagingAccessCatalog.empty(String platform) {
904
+ return MessagingAccessCatalog(
905
+ platform: platform,
906
+ policy: const MessagingAccessPolicy.defaults(),
907
+ capabilities: const MessagingAccessCapabilities(),
908
+ discoveredTargets: const <MessagingAccessTarget>[],
909
+ suggestedTargets: const <MessagingAccessTarget>[],
910
+ summary: 'Access policy',
911
+ );
912
+ }
913
+
914
+ final String platform;
915
+ final MessagingAccessPolicy policy;
916
+ final MessagingAccessCapabilities capabilities;
917
+ final List<MessagingAccessTarget> discoveredTargets;
918
+ final List<MessagingAccessTarget> suggestedTargets;
919
+ final String summary;
920
+ }
921
+
922
+ class BlockedSenderNotice {
923
+ const BlockedSenderNotice({
924
+ required this.id,
925
+ required this.platform,
926
+ required this.chatId,
927
+ required this.sender,
928
+ required this.senderName,
929
+ required this.meta,
930
+ required this.suggestions,
931
+ });
932
+
933
+ factory BlockedSenderNotice.fromSocket(Map<String, dynamic> json) {
934
+ final platform = json['platform']?.toString() ?? 'web';
935
+ final sender = json['sender']?.toString();
936
+ final senderName = json['senderName']?.toString();
937
+ final chatId = json['chatId']?.toString();
938
+ final meta = (json['meta']?.toString() ?? '').trim();
939
+ final suggestionsRaw = json['suggestions'];
940
+ final suggestions = suggestionsRaw is List
941
+ ? suggestionsRaw
942
+ .whereType<Map>()
943
+ .map(
944
+ (item) => QuickAllowSuggestion.fromJson(
945
+ platform,
946
+ Map<String, dynamic>.from(item),
947
+ ),
948
+ )
949
+ .where((item) => item.rule.value.isNotEmpty)
950
+ .toList()
951
+ : const <QuickAllowSuggestion>[];
952
+
953
+ return BlockedSenderNotice(
954
+ id: '$platform:${chatId ?? ''}:${sender ?? ''}',
955
+ platform: platform,
956
+ chatId: chatId,
957
+ sender: sender,
958
+ senderName: senderName,
959
+ meta: meta,
960
+ suggestions: suggestions,
961
+ );
962
+ }
963
+
964
+ final String id;
965
+ final String platform;
966
+ final String? chatId;
967
+ final String? sender;
968
+ final String? senderName;
969
+ final String meta;
970
+ final List<QuickAllowSuggestion> suggestions;
971
+
972
+ String get senderLabel =>
973
+ senderName?.ifEmpty(sender ?? platform.toUpperCase()) ??
974
+ sender?.ifEmpty(platform.toUpperCase()) ??
975
+ platform.toUpperCase();
976
+ }
977
+
978
+ class QuickAllowSuggestion {
979
+ const QuickAllowSuggestion({
980
+ required this.label,
981
+ required this.bucket,
982
+ required this.rule,
983
+ });
984
+
985
+ factory QuickAllowSuggestion.fromJson(
986
+ String platform,
987
+ Map<String, dynamic> json,
988
+ ) {
989
+ final ruleJson = _jsonMap(json['rule']);
990
+ final prefixedId = json['prefixedId']?.toString().trim() ?? '';
991
+ final MessagingAccessRule? parsedRule = ruleJson.isNotEmpty
992
+ ? MessagingAccessRule.fromJson(ruleJson)
993
+ : _ruleFromPrefixedEntry(platform, prefixedId);
994
+ if (parsedRule == null) {
995
+ return const QuickAllowSuggestion(
996
+ label: 'Allow sender',
997
+ bucket: 'sharedActorRules',
998
+ rule: MessagingAccessRule(scope: 'chat', value: ''),
999
+ );
1000
+ }
1001
+ return QuickAllowSuggestion(
1002
+ label:
1003
+ json['label']?.toString().ifEmpty('Allow sender') ?? 'Allow sender',
1004
+ bucket: json['bucket']?.toString().ifEmpty('sharedActorRules') ??
1005
+ 'sharedActorRules',
1006
+ rule: parsedRule,
1007
+ );
1008
+ }
1009
+
1010
+ final String label;
1011
+ final String bucket;
1012
+ final MessagingAccessRule rule;
1013
+ }
1014
+
1015
+ MessagingAccessRule? _ruleFromPrefixedEntry(String platform, String entry) {
1016
+ final normalized = _normalizeSuggestedWhitelistEntry(platform, entry);
1017
+ if (normalized.isEmpty) return null;
1018
+ if (platform == 'telnyx' || platform == 'whatsapp') {
1019
+ return MessagingAccessRule(scope: 'phone_number', value: normalized);
1020
+ }
1021
+ final match = RegExp(r'^([a-z_]+):(.*)$').firstMatch(normalized);
1022
+ if (match != null) {
1023
+ final scope = match.group(1) ?? '';
1024
+ final value = match.group(2) ?? '';
1025
+ if (scope.isEmpty || value.isEmpty) return null;
1026
+ return MessagingAccessRule(scope: scope == 'guild' ? 'server' : scope, value: value);
1027
+ }
1028
+ return MessagingAccessRule(scope: 'chat', value: normalized);
1029
+ }
1030
+
1031
+ class RecordingSessionItem {
1032
+ const RecordingSessionItem({
1033
+ required this.id,
1034
+ required this.title,
1035
+ required this.platform,
1036
+ required this.status,
1037
+ required this.startedAt,
1038
+ required this.endedAt,
1039
+ required this.durationMs,
1040
+ required this.transcriptText,
1041
+ required this.lastError,
1042
+ required this.sources,
1043
+ required this.transcriptSegments,
1044
+ this.structuredContent = const <String, dynamic>{},
1045
+ });
1046
+
1047
+ factory RecordingSessionItem.fromJson(Map<dynamic, dynamic> json) {
1048
+ final rawSources = json['sources'];
1049
+ final sourceRows = rawSources is List
1050
+ ? rawSources
1051
+ : rawSources is Map
1052
+ ? rawSources.values.toList(growable: false)
1053
+ : const <dynamic>[];
1054
+ final rawSegments = json['transcriptSegments'];
1055
+ final transcriptSegmentRows = rawSegments is List
1056
+ ? rawSegments
1057
+ : rawSegments is Map
1058
+ ? rawSegments.values.toList(growable: false)
1059
+ : const <dynamic>[];
1060
+ return RecordingSessionItem(
1061
+ id: json['id']?.toString() ?? '',
1062
+ title: json['title']?.toString().ifEmpty('Recording') ?? 'Recording',
1063
+ platform: json['platform']?.toString() ?? 'unknown',
1064
+ status: json['status']?.toString() ?? 'recording',
1065
+ startedAt: _parseTimestamp(json['startedAt']?.toString()),
1066
+ endedAt: _parseOptionalTimestamp(json['endedAt']?.toString()),
1067
+ durationMs: _asInt(json['durationMs']),
1068
+ transcriptText: json['transcriptText']?.toString() ?? '',
1069
+ lastError: json['lastError']?.toString(),
1070
+ sources: sourceRows
1071
+ .whereType<Map<dynamic, dynamic>>()
1072
+ .map(RecordingSourceItem.fromJson)
1073
+ .toList(),
1074
+ transcriptSegments: transcriptSegmentRows
1075
+ .whereType<Map<dynamic, dynamic>>()
1076
+ .map(RecordingTranscriptSegment.fromJson)
1077
+ .toList(),
1078
+ structuredContent: _jsonMap(json['structuredContent']),
1079
+ );
1080
+ }
1081
+
1082
+ final String id;
1083
+ final String title;
1084
+ final String platform;
1085
+ final String status;
1086
+ final DateTime startedAt;
1087
+ final DateTime? endedAt;
1088
+ final int durationMs;
1089
+ final String transcriptText;
1090
+ final String? lastError;
1091
+ final List<RecordingSourceItem> sources;
1092
+ final List<RecordingTranscriptSegment> transcriptSegments;
1093
+ final Map<String, dynamic> structuredContent;
1094
+
1095
+ String get startedAtLabel => _formatTimestamp(startedAt);
1096
+
1097
+ String get platformLabel {
1098
+ switch (platform) {
1099
+ case 'web':
1100
+ return 'Web';
1101
+ case 'android':
1102
+ return 'Android';
1103
+ default:
1104
+ return platform.ifEmpty('Unknown');
1105
+ }
1106
+ }
1107
+
1108
+ String get durationLabel => _formatDuration(durationMs);
1109
+
1110
+ String get statusLabel {
1111
+ switch (status) {
1112
+ case 'recording':
1113
+ return 'Recording';
1114
+ case 'processing':
1115
+ return 'Processing';
1116
+ case 'completed':
1117
+ return 'Completed';
1118
+ case 'failed':
1119
+ return 'Failed';
1120
+ case 'cancelled':
1121
+ return 'Cancelled';
1122
+ default:
1123
+ return status;
1124
+ }
1125
+ }
1126
+
1127
+ Color get statusColor {
1128
+ switch (status) {
1129
+ case 'recording':
1130
+ return _danger;
1131
+ case 'processing':
1132
+ return _warning;
1133
+ case 'completed':
1134
+ return _success;
1135
+ case 'failed':
1136
+ return _danger;
1137
+ case 'cancelled':
1138
+ return _textSecondary;
1139
+ default:
1140
+ return _textSecondary;
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ class RecordingSourceItem {
1146
+ const RecordingSourceItem({
1147
+ required this.sourceKey,
1148
+ required this.sourceKind,
1149
+ required this.mediaKind,
1150
+ required this.mimeType,
1151
+ required this.durationMs,
1152
+ required this.chunkCount,
1153
+ });
1154
+
1155
+ factory RecordingSourceItem.fromJson(Map<dynamic, dynamic> json) {
1156
+ return RecordingSourceItem(
1157
+ sourceKey: json['sourceKey']?.toString() ?? '',
1158
+ sourceKind: json['sourceKind']?.toString() ?? '',
1159
+ mediaKind: json['mediaKind']?.toString() ?? '',
1160
+ mimeType: json['mimeType']?.toString() ?? '',
1161
+ durationMs: _asInt(json['durationMs']),
1162
+ chunkCount: _asInt(json['chunkCount']),
1163
+ );
1164
+ }
1165
+
1166
+ final String sourceKey;
1167
+ final String sourceKind;
1168
+ final String mediaKind;
1169
+ final String mimeType;
1170
+ final int durationMs;
1171
+ final int chunkCount;
1172
+
1173
+ String get label {
1174
+ switch (sourceKind) {
1175
+ case 'screen-share':
1176
+ return 'Screen';
1177
+ case 'microphone':
1178
+ return 'Microphone';
1179
+ default:
1180
+ return sourceKind.ifEmpty(sourceKey);
1181
+ }
1182
+ }
1183
+
1184
+ String get durationLabel => _formatDuration(durationMs);
1185
+ }
1186
+
1187
+ class RecordingTranscriptSegment {
1188
+ const RecordingTranscriptSegment({
1189
+ required this.id,
1190
+ required this.speaker,
1191
+ required this.text,
1192
+ required this.startMs,
1193
+ });
1194
+
1195
+ factory RecordingTranscriptSegment.fromJson(Map<dynamic, dynamic> json) {
1196
+ return RecordingTranscriptSegment(
1197
+ id: _asInt(json['id']),
1198
+ speaker:
1199
+ json['speaker']?.toString() ?? json['sourceKey']?.toString() ?? '',
1200
+ text: json['text']?.toString() ?? '',
1201
+ startMs: _asInt(json['startMs']),
1202
+ );
1203
+ }
1204
+
1205
+ final int id;
1206
+ final String speaker;
1207
+ final String text;
1208
+ final int startMs;
1209
+
1210
+ String get timestampLabel => _formatDuration(startMs);
1211
+
1212
+ String get displayText {
1213
+ if (speaker.trim().isEmpty) {
1214
+ return text;
1215
+ }
1216
+ return '${speaker.replaceAll('-', ' ')}: $text';
1217
+ }
1218
+ }
1219
+
1220
+ class VoiceAssistantTurnResult {
1221
+ const VoiceAssistantTurnResult({
1222
+ required this.session,
1223
+ required this.transcript,
1224
+ required this.replyText,
1225
+ required this.audioMimeType,
1226
+ required this.audioBytes,
1227
+ this.runId,
1228
+ this.ttsProvider,
1229
+ this.ttsModel,
1230
+ this.ttsVoice,
1231
+ this.ttsError,
1232
+ });
1233
+
1234
+ factory VoiceAssistantTurnResult.fromJson(Map<dynamic, dynamic> json) {
1235
+ final audioBase64 = json['audioBase64']?.toString() ?? '';
1236
+ return VoiceAssistantTurnResult(
1237
+ session: RecordingSessionItem.fromJson(_jsonMap(json['session'])),
1238
+ transcript: json['transcript']?.toString() ?? '',
1239
+ replyText: json['replyText']?.toString() ?? '',
1240
+ audioMimeType: json['audioMimeType']?.toString() ?? 'audio/mpeg',
1241
+ audioBytes: audioBase64.trim().isEmpty
1242
+ ? Uint8List(0)
1243
+ : base64Decode(audioBase64),
1244
+ runId: json['runId']?.toString(),
1245
+ ttsProvider: json['ttsProvider']?.toString(),
1246
+ ttsModel: json['ttsModel']?.toString(),
1247
+ ttsVoice: json['ttsVoice']?.toString(),
1248
+ ttsError: json['ttsError']?.toString(),
1249
+ );
1250
+ }
1251
+
1252
+ final RecordingSessionItem session;
1253
+ final String transcript;
1254
+ final String replyText;
1255
+ final String audioMimeType;
1256
+ final Uint8List audioBytes;
1257
+ final String? runId;
1258
+ final String? ttsProvider;
1259
+ final String? ttsModel;
1260
+ final String? ttsVoice;
1261
+ final String? ttsError;
1262
+ }
1263
+
1264
+ class LiveVoiceBufferedChunk {
1265
+ LiveVoiceBufferedChunk({
1266
+ required this.sequence,
1267
+ required Uint8List bytes,
1268
+ this.sent = false,
1269
+ }) : bytes = Uint8List.fromList(bytes);
1270
+
1271
+ final int sequence;
1272
+ final Uint8List bytes;
1273
+ bool sent;
1274
+ }
1275
+
1276
+ class VoiceAssistantLiveState {
1277
+ VoiceAssistantLiveState({
1278
+ this.sessionId = '',
1279
+ this.runtimeMode = 'legacy',
1280
+ this.provider = 'openai',
1281
+ this.model = '',
1282
+ this.voice = '',
1283
+ this.transportState = 'connected',
1284
+ this.state = 'idle',
1285
+ this.partialTranscript = '',
1286
+ this.finalTranscript = '',
1287
+ this.interimAssistantText = '',
1288
+ this.finalAssistantText = '',
1289
+ this.assistantText = '',
1290
+ this.audioMimeType = 'audio/mpeg',
1291
+ List<Uint8List>? audioQueue,
1292
+ this.audioStreamDone = false,
1293
+ this.recoverableUntil,
1294
+ this.error,
1295
+ }) : audioQueue = audioQueue ?? const <Uint8List>[];
1296
+
1297
+ final String sessionId;
1298
+ final String runtimeMode;
1299
+ final String provider;
1300
+ final String model;
1301
+ final String voice;
1302
+ final String transportState;
1303
+ final String state;
1304
+ final String partialTranscript;
1305
+ final String finalTranscript;
1306
+ final String interimAssistantText;
1307
+ final String finalAssistantText;
1308
+ final String assistantText;
1309
+ final String audioMimeType;
1310
+ final List<Uint8List> audioQueue;
1311
+ final bool audioStreamDone;
1312
+ final DateTime? recoverableUntil;
1313
+ final String? error;
1314
+
1315
+ bool get hasActiveSession => sessionId.trim().isNotEmpty;
1316
+ bool get isLive => runtimeMode == 'live';
1317
+ bool get isListening => state == 'listening';
1318
+ bool get isBusy =>
1319
+ state == 'transcribing' || state == 'thinking' || state == 'speaking';
1320
+ bool get isRecoverable =>
1321
+ recoverableUntil != null && recoverableUntil!.isAfter(DateTime.now());
1322
+
1323
+ VoiceAssistantLiveState copyWith({
1324
+ String? sessionId,
1325
+ String? runtimeMode,
1326
+ String? provider,
1327
+ String? model,
1328
+ String? voice,
1329
+ String? transportState,
1330
+ String? state,
1331
+ String? partialTranscript,
1332
+ String? finalTranscript,
1333
+ String? interimAssistantText,
1334
+ String? finalAssistantText,
1335
+ String? assistantText,
1336
+ String? audioMimeType,
1337
+ List<Uint8List>? audioQueue,
1338
+ bool? audioStreamDone,
1339
+ DateTime? recoverableUntil,
1340
+ String? error,
1341
+ bool clearError = false,
1342
+ bool clearAudio = false,
1343
+ bool clearRecoverableUntil = false,
1344
+ }) {
1345
+ return VoiceAssistantLiveState(
1346
+ sessionId: sessionId ?? this.sessionId,
1347
+ runtimeMode: runtimeMode ?? this.runtimeMode,
1348
+ provider: provider ?? this.provider,
1349
+ model: model ?? this.model,
1350
+ voice: voice ?? this.voice,
1351
+ transportState: transportState ?? this.transportState,
1352
+ state: state ?? this.state,
1353
+ partialTranscript: partialTranscript ?? this.partialTranscript,
1354
+ finalTranscript: finalTranscript ?? this.finalTranscript,
1355
+ interimAssistantText: interimAssistantText ?? this.interimAssistantText,
1356
+ finalAssistantText: finalAssistantText ?? this.finalAssistantText,
1357
+ assistantText: assistantText ?? this.assistantText,
1358
+ audioMimeType: audioMimeType ?? this.audioMimeType,
1359
+ audioQueue: clearAudio
1360
+ ? const <Uint8List>[]
1361
+ : (audioQueue ?? this.audioQueue),
1362
+ audioStreamDone: clearAudio
1363
+ ? false
1364
+ : (audioStreamDone ?? this.audioStreamDone),
1365
+ recoverableUntil: clearRecoverableUntil
1366
+ ? null
1367
+ : (recoverableUntil ?? this.recoverableUntil),
1368
+ error: clearError ? null : (error ?? this.error),
1369
+ );
1370
+ }
1371
+ }
1372
+
1373
+ class RunDetailSnapshot {
1374
+ const RunDetailSnapshot({
1375
+ required this.run,
1376
+ required this.steps,
1377
+ required this.response,
1378
+ });
1379
+
1380
+ factory RunDetailSnapshot.fromJson(Map<dynamic, dynamic> json) {
1381
+ return RunDetailSnapshot(
1382
+ run: RunSummary.fromJson(_jsonMap(json['run'])),
1383
+ steps: _jsonMapList(
1384
+ json['steps'],
1385
+ fallbackToMapValues: true,
1386
+ ).map(RunStepItem.fromJson).toList(),
1387
+ response: json['response']?.toString() ?? '',
1388
+ );
1389
+ }
1390
+
1391
+ final RunSummary run;
1392
+ final List<RunStepItem> steps;
1393
+ final String response;
1394
+
1395
+ int get completedTools => steps
1396
+ .where((step) => step.toolName.isNotEmpty && step.status == 'completed')
1397
+ .length;
1398
+
1399
+ int get failedTools => steps.where((step) => step.status == 'failed').length;
1400
+
1401
+ int get helperCount => steps.where((step) {
1402
+ final label = '${step.type} ${step.toolName}'.toLowerCase();
1403
+ return label.contains('subagent') || label.contains('helper');
1404
+ }).length;
1405
+
1406
+ int get webStepCount => steps.where((step) => step.isWebRelated).length;
1407
+
1408
+ int get planningStepCount =>
1409
+ steps.where((step) => step.isPlanningRelated).length;
1410
+ }
1411
+
1412
+ class RunStepItem {
1413
+ const RunStepItem({
1414
+ required this.id,
1415
+ required this.index,
1416
+ required this.type,
1417
+ required this.description,
1418
+ required this.status,
1419
+ required this.toolName,
1420
+ required this.toolInput,
1421
+ required this.result,
1422
+ required this.error,
1423
+ required this.tokensUsed,
1424
+ required this.startedAt,
1425
+ required this.completedAt,
1426
+ });
1427
+
1428
+ factory RunStepItem.fromJson(Map<dynamic, dynamic> json) {
1429
+ return RunStepItem(
1430
+ id: json['id']?.toString() ?? '',
1431
+ index: _asInt(json['step_index']),
1432
+ type: json['type']?.toString().ifEmpty('step') ?? 'step',
1433
+ description: json['description']?.toString() ?? '',
1434
+ status: json['status']?.toString().ifEmpty('pending') ?? 'pending',
1435
+ toolName: json['tool_name']?.toString() ?? '',
1436
+ toolInput: json['tool_input']?.toString() ?? '',
1437
+ result: json['result']?.toString() ?? '',
1438
+ error: json['error']?.toString() ?? '',
1439
+ tokensUsed: _asInt(json['tokens_used']),
1440
+ startedAt: _parseOptionalTimestamp(json['started_at']?.toString()),
1441
+ completedAt: _parseOptionalTimestamp(json['completed_at']?.toString()),
1442
+ );
1443
+ }
1444
+
1445
+ final String id;
1446
+ final int index;
1447
+ final String type;
1448
+ final String description;
1449
+ final String status;
1450
+ final String toolName;
1451
+ final String toolInput;
1452
+ final String result;
1453
+ final String error;
1454
+ final int tokensUsed;
1455
+ final DateTime? startedAt;
1456
+ final DateTime? completedAt;
1457
+
1458
+ int get displayIndex => index + 1;
1459
+
1460
+ String get label => toolName.ifEmpty(type.replaceAll('_', ' '));
1461
+
1462
+ String get typeLabel => _titleCase(type.replaceAll('_', ' '));
1463
+
1464
+ String get statusLabel => _titleCase(status.replaceAll('_', ' '));
1465
+
1466
+ String get inputSummary =>
1467
+ _summarizeToolArgs(_decodeMaybeJson(toolInput)).ifEmpty('');
1468
+
1469
+ String? get startedAtLabel =>
1470
+ startedAt == null ? null : _formatTimestamp(startedAt!);
1471
+
1472
+ Duration? get duration => startedAt == null || completedAt == null
1473
+ ? null
1474
+ : completedAt!.difference(startedAt!);
1475
+
1476
+ String? get durationLabel =>
1477
+ duration == null ? null : _formatElapsed(duration!);
1478
+
1479
+ String get summary {
1480
+ final resultText = _summarizeToolResult(_decodeMaybeJson(result));
1481
+ if (error.trim().isNotEmpty) {
1482
+ return error;
1483
+ }
1484
+ if (resultText.trim().isNotEmpty) {
1485
+ return resultText;
1486
+ }
1487
+ return description.ifEmpty('No details captured.');
1488
+ }
1489
+
1490
+ String get compactSummary => _condenseRunText(summary, maxLength: 140);
1491
+
1492
+ String get laneLabel {
1493
+ if (isPlanningRelated) {
1494
+ return 'Planning';
1495
+ }
1496
+ if (isHelperRelated) {
1497
+ return 'Helper';
1498
+ }
1499
+ if (isWebRelated) {
1500
+ return 'Web';
1501
+ }
1502
+ if (type == 'verification') {
1503
+ return 'Verification';
1504
+ }
1505
+ return 'Execution';
1506
+ }
1507
+
1508
+ bool get isPlanningRelated =>
1509
+ type == 'analysis' || type == 'planning' || toolName == 'analysis';
1510
+
1511
+ bool get isHelperRelated {
1512
+ final label = '${type.toLowerCase()} ${toolName.toLowerCase()}';
1513
+ return label.contains('subagent') || label.contains('helper');
1514
+ }
1515
+
1516
+ bool get isBrowserRelated {
1517
+ final label = '${type.toLowerCase()} ${toolName.toLowerCase()}';
1518
+ return label.contains('browser') ||
1519
+ label.contains('page') ||
1520
+ label.contains('screenshot');
1521
+ }
1522
+
1523
+ bool get isMessagingRelated {
1524
+ final label = '${type.toLowerCase()} ${toolName.toLowerCase()}';
1525
+ return label.contains('message') ||
1526
+ label.contains('telegram') ||
1527
+ label.contains('discord') ||
1528
+ label.contains('whatsapp') ||
1529
+ label.contains('slack');
1530
+ }
1531
+
1532
+ bool get isWebRelated => isBrowserRelated || isMessagingRelated;
1533
+
1534
+ IconData get laneIcon {
1535
+ if (isPlanningRelated) {
1536
+ return Icons.route_outlined;
1537
+ }
1538
+ if (isHelperRelated) {
1539
+ return Icons.account_tree_outlined;
1540
+ }
1541
+ if (isBrowserRelated) {
1542
+ return Icons.language_outlined;
1543
+ }
1544
+ if (isMessagingRelated) {
1545
+ return Icons.chat_bubble_outline;
1546
+ }
1547
+ if (type == 'verification') {
1548
+ return Icons.verified_outlined;
1549
+ }
1550
+ if (status == 'failed') {
1551
+ return Icons.error_outline;
1552
+ }
1553
+ return Icons.build_outlined;
1554
+ }
1555
+
1556
+ Color get statusColor {
1557
+ switch (status) {
1558
+ case 'completed':
1559
+ return _success;
1560
+ case 'failed':
1561
+ return _danger;
1562
+ case 'running':
1563
+ return _warning;
1564
+ default:
1565
+ return _textSecondary;
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ String _condenseRunText(String value, {int maxLength = 160}) {
1571
+ final normalized = value.replaceAll(RegExp(r'\s+'), ' ').trim();
1572
+ if (normalized.length <= maxLength) {
1573
+ return normalized;
1574
+ }
1575
+ return '${normalized.substring(0, maxLength - 1).trimRight()}…';
1576
+ }
1577
+
1578
+ dynamic _decodeMaybeJson(dynamic value) {
1579
+ if (value == null) {
1580
+ return null;
1581
+ }
1582
+ if (value is Map || value is List) {
1583
+ return value;
1584
+ }
1585
+ if (value is String && value.trim().isNotEmpty) {
1586
+ try {
1587
+ return jsonDecode(value);
1588
+ } catch (_) {}
1589
+ }
1590
+ return value;
1591
+ }
1592
+
1593
+ class ChatEntry {
1594
+ const ChatEntry({
1595
+ required this.id,
1596
+ required this.role,
1597
+ required this.content,
1598
+ required this.platform,
1599
+ required this.createdAt,
1600
+ this.runId,
1601
+ this.senderName,
1602
+ this.metadata = const <String, dynamic>{},
1603
+ this.toolCalls = const <Map<String, dynamic>>[],
1604
+ this.transient = false,
1605
+ });
1606
+
1607
+ factory ChatEntry.fromJson(Map<dynamic, dynamic> json) {
1608
+ return ChatEntry(
1609
+ id: json['id']?.toString() ?? '',
1610
+ role: json['role']?.toString() ?? 'assistant',
1611
+ content: json['content']?.toString() ?? '',
1612
+ platform: json['platform']?.toString() ?? 'web',
1613
+ runId: json['run_id']?.toString(),
1614
+ senderName: json['sender_name']?.toString(),
1615
+ metadata: _jsonMap(_decodeMaybeJson(json['metadata'])),
1616
+ toolCalls: _jsonMapList(
1617
+ _decodeMaybeJson(json['tool_calls']),
1618
+ fallbackToMapValues: true,
1619
+ ),
1620
+ createdAt: _parseTimestamp(json['created_at']?.toString()),
1621
+ );
1622
+ }
1623
+
1624
+ final String id;
1625
+ final String role;
1626
+ final String content;
1627
+ final String platform;
1628
+ final String? runId;
1629
+ final String? senderName;
1630
+ final Map<String, dynamic> metadata;
1631
+ final List<Map<String, dynamic>> toolCalls;
1632
+ final DateTime createdAt;
1633
+ final bool transient;
1634
+
1635
+ String get createdAtLabel => _formatTimestamp(createdAt);
1636
+
1637
+ String? get platformTag {
1638
+ if (platform == 'live') {
1639
+ return 'LIVE';
1640
+ }
1641
+ if (platform != 'web' && platform != 'flutter' && platform.isNotEmpty) {
1642
+ return platform.toUpperCase();
1643
+ }
1644
+ return null;
1645
+ }
1646
+ }
1647
+
1648
+ class AgentProfile {
1649
+ const AgentProfile({
1650
+ required this.id,
1651
+ required this.slug,
1652
+ required this.displayName,
1653
+ required this.description,
1654
+ required this.responsibilities,
1655
+ required this.instructions,
1656
+ required this.status,
1657
+ required this.isDefault,
1658
+ required this.canDelegate,
1659
+ required this.canBeDelegatedTo,
1660
+ required this.delegateTargets,
1661
+ });
1662
+
1663
+ factory AgentProfile.fromJson(Map<dynamic, dynamic> json) {
1664
+ final displayName =
1665
+ json['displayName']?.toString().ifEmpty('Agent') ??
1666
+ json['display_name']?.toString().ifEmpty('Agent') ??
1667
+ 'Agent';
1668
+ return AgentProfile(
1669
+ id: json['id']?.toString() ?? '',
1670
+ slug:
1671
+ json['slug']?.toString().ifEmpty(
1672
+ displayName.toLowerCase().replaceAll(RegExp(r'\s+'), '-'),
1673
+ ) ??
1674
+ 'agent',
1675
+ displayName: displayName,
1676
+ description: json['description']?.toString() ?? '',
1677
+ responsibilities: json['responsibilities']?.toString() ?? '',
1678
+ instructions: json['instructions']?.toString() ?? '',
1679
+ status: json['status']?.toString().ifEmpty('active') ?? 'active',
1680
+ isDefault:
1681
+ json['isDefault'] == true ||
1682
+ json['isDefault'] == 1 ||
1683
+ json['is_default'] == true ||
1684
+ json['is_default'] == 1,
1685
+ canDelegate:
1686
+ json['canDelegate'] == true ||
1687
+ json['canDelegate'] == 1 ||
1688
+ json['can_delegate'] == true ||
1689
+ json['can_delegate'] == 1,
1690
+ canBeDelegatedTo:
1691
+ json['canBeDelegatedTo'] != false &&
1692
+ json['canBeDelegatedTo'] != 0 &&
1693
+ json['can_be_delegated_to'] != false &&
1694
+ json['can_be_delegated_to'] != 0,
1695
+ delegateTargets: _jsonStringList(
1696
+ json['delegateTargets'] ?? json['delegate_targets'],
1697
+ fallbackToMapValues: true,
1698
+ ),
1699
+ );
1700
+ }
1701
+
1702
+ final String id;
1703
+ final String slug;
1704
+ final String displayName;
1705
+ final String description;
1706
+ final String responsibilities;
1707
+ final String instructions;
1708
+ final String status;
1709
+ final bool isDefault;
1710
+ final bool canDelegate;
1711
+ final bool canBeDelegatedTo;
1712
+ final List<String> delegateTargets;
1713
+
1714
+ bool get isMain => slug == 'main';
1715
+ bool get isArchived => status == 'archived';
1716
+ String get label => isDefault ? '$displayName (default)' : displayName;
1717
+ bool get delegatesToAnyEligibleAgent =>
1718
+ canDelegate && delegateTargets.isEmpty;
1719
+ }
1720
+
1721
+ class ModelMeta {
1722
+ const ModelMeta({
1723
+ required this.id,
1724
+ required this.label,
1725
+ required this.provider,
1726
+ required this.purpose,
1727
+ this.available = true,
1728
+ this.providerStatus = '',
1729
+ this.providerStatusLabel = '',
1730
+ });
1731
+
1732
+ factory ModelMeta.fromJson(Map<dynamic, dynamic> json) {
1733
+ return ModelMeta(
1734
+ id: json['id']?.toString() ?? '',
1735
+ label: json['label']?.toString() ?? '',
1736
+ provider: json['provider']?.toString() ?? '',
1737
+ purpose: json['purpose']?.toString() ?? '',
1738
+ available: json['available'] != false,
1739
+ providerStatus: json['providerStatus']?.toString() ?? '',
1740
+ providerStatusLabel: json['providerStatusLabel']?.toString() ?? '',
1741
+ );
1742
+ }
1743
+
1744
+ final String id;
1745
+ final String label;
1746
+ final String provider;
1747
+ final String purpose;
1748
+ final bool available;
1749
+ final String providerStatus;
1750
+ final String providerStatusLabel;
1751
+ }
1752
+
1753
+ class AiProviderConfig {
1754
+ const AiProviderConfig({
1755
+ required this.id,
1756
+ required this.enabled,
1757
+ required this.baseUrl,
1758
+ });
1759
+
1760
+ factory AiProviderConfig.empty(String id) {
1761
+ return AiProviderConfig(
1762
+ id: id,
1763
+ enabled: true,
1764
+ baseUrl: id == 'ollama' ? 'http://localhost:11434' : '',
1765
+ );
1766
+ }
1767
+
1768
+ factory AiProviderConfig.fromJson(String id, dynamic json) {
1769
+ final map = json is Map
1770
+ ? Map<String, dynamic>.from(json)
1771
+ : const <String, dynamic>{};
1772
+ return AiProviderConfig(
1773
+ id: id,
1774
+ enabled: map['enabled'] != false,
1775
+ baseUrl:
1776
+ map['baseUrl']?.toString() ??
1777
+ (id == 'ollama' ? 'http://localhost:11434' : ''),
1778
+ );
1779
+ }
1780
+
1781
+ final String id;
1782
+ final bool enabled;
1783
+ final String baseUrl;
1784
+
1785
+ Map<String, dynamic> toJson() {
1786
+ return <String, dynamic>{'enabled': enabled, 'baseUrl': baseUrl.trim()};
1787
+ }
1788
+ }
1789
+
1790
+ class AiProviderMeta {
1791
+ const AiProviderMeta({
1792
+ required this.id,
1793
+ required this.label,
1794
+ required this.description,
1795
+ required this.enabled,
1796
+ required this.available,
1797
+ required this.supportsApiKey,
1798
+ required this.supportsBaseUrl,
1799
+ required this.defaultBaseUrl,
1800
+ required this.credentialConfigured,
1801
+ required this.baseUrl,
1802
+ required this.status,
1803
+ required this.statusLabel,
1804
+ required this.availabilityReason,
1805
+ required this.modelCount,
1806
+ required this.availableModelCount,
1807
+ });
1808
+
1809
+ factory AiProviderMeta.fromJson(Map<dynamic, dynamic> json) {
1810
+ return AiProviderMeta(
1811
+ id: json['id']?.toString() ?? '',
1812
+ label: json['label']?.toString() ?? '',
1813
+ description: json['description']?.toString() ?? '',
1814
+ enabled: json['enabled'] != false,
1815
+ available: json['available'] == true,
1816
+ supportsApiKey: json['supportsApiKey'] == true,
1817
+ supportsBaseUrl: json['supportsBaseUrl'] == true,
1818
+ defaultBaseUrl: json['defaultBaseUrl']?.toString() ?? '',
1819
+ credentialConfigured: json['credentialConfigured'] == true,
1820
+ baseUrl: json['baseUrl']?.toString() ?? '',
1821
+ status: json['status']?.toString() ?? '',
1822
+ statusLabel: json['statusLabel']?.toString() ?? '',
1823
+ availabilityReason: json['availabilityReason']?.toString() ?? '',
1824
+ modelCount: _asInt(json['modelCount']),
1825
+ availableModelCount: _asInt(json['availableModelCount']),
1826
+ );
1827
+ }
1828
+
1829
+ final String id;
1830
+ final String label;
1831
+ final String description;
1832
+ final bool enabled;
1833
+ final bool available;
1834
+ final bool supportsApiKey;
1835
+ final bool supportsBaseUrl;
1836
+ final String defaultBaseUrl;
1837
+ final bool credentialConfigured;
1838
+ final String baseUrl;
1839
+ final String status;
1840
+ final String statusLabel;
1841
+ final String availabilityReason;
1842
+ final int modelCount;
1843
+ final int availableModelCount;
1844
+
1845
+ IconData get icon {
1846
+ switch (id) {
1847
+ case 'openai':
1848
+ return Icons.auto_awesome;
1849
+ case 'anthropic':
1850
+ return Icons.edit_note_outlined;
1851
+ case 'google':
1852
+ return Icons.multitrack_audio_outlined;
1853
+ case 'grok':
1854
+ return Icons.bolt_outlined;
1855
+ case 'ollama':
1856
+ return Icons.storage_outlined;
1857
+ case 'github-copilot':
1858
+ return Icons.code;
1859
+ case 'openai-codex':
1860
+ return Icons.psychology_outlined;
1861
+ default:
1862
+ return Icons.hub_outlined;
1863
+ }
1864
+ }
1865
+
1866
+ Color get statusColor {
1867
+ switch (status) {
1868
+ case 'ready':
1869
+ case 'healthy':
1870
+ case 'configured':
1871
+ case 'local':
1872
+ return _success;
1873
+ case 'offline':
1874
+ return _danger;
1875
+ case 'disabled':
1876
+ return _textSecondary;
1877
+ case 'needs_key':
1878
+ case 'needs_setup':
1879
+ return _warning;
1880
+ default:
1881
+ return _info;
1882
+ }
1883
+ }
1884
+
1885
+ String get modelSummary {
1886
+ if (modelCount == 0) {
1887
+ return 'No models discovered yet';
1888
+ }
1889
+ if (availableModelCount == modelCount) {
1890
+ return '$modelCount models ready';
1891
+ }
1892
+ return '$availableModelCount of $modelCount models ready';
1893
+ }
1894
+ }
1895
+
1896
+ class RunSummary {
1897
+ const RunSummary({
1898
+ required this.id,
1899
+ required this.title,
1900
+ required this.status,
1901
+ required this.model,
1902
+ required this.triggerSource,
1903
+ required this.totalTokens,
1904
+ required this.createdAt,
1905
+ this.completedAt,
1906
+ this.error = '',
1907
+ });
1908
+
1909
+ factory RunSummary.fromJson(Map<dynamic, dynamic> json) {
1910
+ return RunSummary(
1911
+ id: json['id']?.toString() ?? '',
1912
+ title: json['title']?.toString() ?? 'Untitled',
1913
+ status: json['status']?.toString() ?? 'unknown',
1914
+ model: json['model']?.toString() ?? '',
1915
+ triggerSource: json['trigger_source']?.toString() ?? '',
1916
+ totalTokens: _asInt(json['total_tokens']),
1917
+ createdAt: _parseTimestamp(json['created_at']?.toString()),
1918
+ completedAt: _parseOptionalTimestamp(json['completed_at']?.toString()),
1919
+ error: json['error']?.toString() ?? '',
1920
+ );
1921
+ }
1922
+
1923
+ final String id;
1924
+ final String title;
1925
+ final String status;
1926
+ final String model;
1927
+ final String triggerSource;
1928
+ final int totalTokens;
1929
+ final DateTime createdAt;
1930
+ final DateTime? completedAt;
1931
+ final String error;
1932
+
1933
+ bool get isFailure => status == 'failed' || status == 'error';
1934
+
1935
+ String get createdAtLabel => _formatTimestamp(createdAt);
1936
+
1937
+ String get totalTokensLabel => _formatNumber(totalTokens);
1938
+
1939
+ String get statusLabel => _titleCase(status.replaceAll('_', ' '));
1940
+
1941
+ String get triggerLabel => triggerSource.ifEmpty('web');
1942
+
1943
+ String get modelLabel => model.ifEmpty('Model pending');
1944
+
1945
+ Duration? get duration => completedAt?.difference(createdAt);
1946
+
1947
+ String get durationLabel =>
1948
+ completedAt == null ? 'In progress' : _formatElapsed(duration!);
1949
+
1950
+ Color get statusColor {
1951
+ switch (status) {
1952
+ case 'completed':
1953
+ return _success;
1954
+ case 'failed':
1955
+ case 'error':
1956
+ return _danger;
1957
+ case 'running':
1958
+ return _warning;
1959
+ default:
1960
+ return _textSecondary;
1961
+ }
1962
+ }
1963
+ }
1964
+
1965
+ class TokenUsageSnapshot {
1966
+ const TokenUsageSnapshot({
1967
+ required this.totalTokens,
1968
+ required this.totalRuns,
1969
+ required this.avgTokensPerRun,
1970
+ required this.last7DaysTokens,
1971
+ required this.last7DaysRuns,
1972
+ });
1973
+
1974
+ factory TokenUsageSnapshot.fromJson(Map<dynamic, dynamic> json) {
1975
+ final totals = json['totals'] is Map
1976
+ ? Map<String, dynamic>.from(json['totals'] as Map)
1977
+ : const <String, dynamic>{};
1978
+ return TokenUsageSnapshot(
1979
+ totalTokens: _asInt(totals['totalTokens']),
1980
+ totalRuns: _asInt(totals['totalRuns']),
1981
+ avgTokensPerRun: _asInt(totals['avgTokensPerRun']),
1982
+ last7DaysTokens: _asInt(totals['last7DaysTokens']),
1983
+ last7DaysRuns: _asInt(totals['last7DaysRuns']),
1984
+ );
1985
+ }
1986
+
1987
+ final int totalTokens;
1988
+ final int totalRuns;
1989
+ final int avgTokensPerRun;
1990
+ final int last7DaysTokens;
1991
+ final int last7DaysRuns;
1992
+
1993
+ String get totalTokensLabel => _formatNumber(totalTokens);
1994
+ String get totalRunsLabel => _formatNumber(totalRuns);
1995
+ String get avgTokensPerRunLabel => _formatNumber(avgTokensPerRun);
1996
+ String get last7DaysTokensLabel => _formatNumber(last7DaysTokens);
1997
+ String get last7DaysRunsLabel => _formatNumber(last7DaysRuns);
1998
+ }
1999
+
2000
+ class UpdateStatusSnapshot {
2001
+ const UpdateStatusSnapshot({
2002
+ this.state = 'idle',
2003
+ this.progress = 0,
2004
+ this.message = 'No update running',
2005
+ this.releaseChannel = 'stable',
2006
+ this.allowSelfUpdate = true,
2007
+ this.deploymentMode = 'self_hosted',
2008
+ this.deploymentProfile = 'private',
2009
+ this.targetBranch,
2010
+ this.npmDistTag,
2011
+ this.versionBefore,
2012
+ this.versionAfter,
2013
+ this.backendVersion,
2014
+ this.installedVersion,
2015
+ this.runtimeValidationReady = true,
2016
+ this.runtimeValidationIssues = const <String>[],
2017
+ this.runtimeAcceleration,
2018
+ this.changelog = const <String>[],
2019
+ this.logs = const <String>[],
2020
+ });
2021
+
2022
+ factory UpdateStatusSnapshot.fromJson(Map<dynamic, dynamic> json) {
2023
+ return UpdateStatusSnapshot(
2024
+ state: json['state']?.toString() ?? 'idle',
2025
+ progress: _asInt(json['progress']).clamp(0, 100),
2026
+ message: json['message']?.toString() ?? 'No update running',
2027
+ releaseChannel: json['releaseChannel']?.toString() ?? 'stable',
2028
+ allowSelfUpdate: json['allowSelfUpdate'] != false,
2029
+ deploymentMode: json['deploymentMode']?.toString() ?? 'self_hosted',
2030
+ deploymentProfile: json['deploymentProfile']?.toString() ?? 'private',
2031
+ targetBranch: json['targetBranch']?.toString(),
2032
+ npmDistTag: json['npmDistTag']?.toString(),
2033
+ versionBefore: json['versionBefore']?.toString(),
2034
+ versionAfter: json['versionAfter']?.toString(),
2035
+ backendVersion: json['backendVersion']?.toString(),
2036
+ installedVersion:
2037
+ json['installedVersion']?.toString() ??
2038
+ json['packageVersion']?.toString(),
2039
+ runtimeValidationReady:
2040
+ _jsonMap(json['runtimeValidation'])['ready'] != false,
2041
+ runtimeValidationIssues: _jsonStringList(
2042
+ _jsonMap(json['runtimeValidation'])['issues'],
2043
+ fallbackToMapValues: true,
2044
+ ),
2045
+ runtimeAcceleration: _jsonMap(
2046
+ _jsonMap(json['runtimeValidation'])['vm'],
2047
+ )['acceleration']?.toString(),
2048
+ changelog: _jsonStringList(json['changelog'], fallbackToMapValues: true),
2049
+ logs: _jsonStringList(json['logs'], fallbackToMapValues: true),
2050
+ );
2051
+ }
2052
+
2053
+ final String state;
2054
+ final int progress;
2055
+ final String message;
2056
+ final String releaseChannel;
2057
+ final bool allowSelfUpdate;
2058
+ final String deploymentMode;
2059
+ final String deploymentProfile;
2060
+ final String? targetBranch;
2061
+ final String? npmDistTag;
2062
+ final String? versionBefore;
2063
+ final String? versionAfter;
2064
+ final String? backendVersion;
2065
+ final String? installedVersion;
2066
+ final bool runtimeValidationReady;
2067
+ final List<String> runtimeValidationIssues;
2068
+ final String? runtimeAcceleration;
2069
+ final List<String> changelog;
2070
+ final List<String> logs;
2071
+
2072
+ String get badgeLabel {
2073
+ switch (state) {
2074
+ case 'running':
2075
+ return 'Running';
2076
+ case 'completed':
2077
+ return 'Completed';
2078
+ case 'failed':
2079
+ return 'Failed';
2080
+ default:
2081
+ return 'Idle';
2082
+ }
2083
+ }
2084
+
2085
+ Color get badgeColor {
2086
+ switch (state) {
2087
+ case 'running':
2088
+ return _info;
2089
+ case 'completed':
2090
+ return _success;
2091
+ case 'failed':
2092
+ return _danger;
2093
+ default:
2094
+ return _textSecondary;
2095
+ }
2096
+ }
2097
+
2098
+ String get releaseChannelLabel =>
2099
+ releaseChannel.toLowerCase() == 'beta' ? 'Beta' : 'Stable';
2100
+
2101
+ String get deploymentProfileLabel =>
2102
+ deploymentProfile.toLowerCase() == 'prod' ? 'Production' : 'Private';
2103
+
2104
+ String get runtimeModeLabel => deploymentProfile.toLowerCase() == 'prod'
2105
+ ? 'Per-user isolated VM runtime'
2106
+ : 'Trusted host runtime';
2107
+
2108
+ String get runtimeValidationLabel =>
2109
+ runtimeValidationReady ? 'Runtime ready' : 'Runtime setup required';
2110
+
2111
+ Color get runtimeValidationColor =>
2112
+ runtimeValidationReady ? _success : _danger;
2113
+
2114
+ String get versionLine {
2115
+ final before = versionBefore?.ifEmpty('—') ?? '—';
2116
+ final after = versionAfter?.ifEmpty('—') ?? '—';
2117
+ final updateVersion = after == '—' ? before : '$before -> $after';
2118
+ final branch = targetBranch?.trim().isNotEmpty == true
2119
+ ? ' | Branch: $targetBranch'
2120
+ : '';
2121
+ final npm = npmDistTag?.trim().isNotEmpty == true
2122
+ ? ' | npm: $npmDistTag'
2123
+ : '';
2124
+ final installed = installedVersion == null
2125
+ ? ''
2126
+ : ' | Installed: $installedVersion';
2127
+ final backend = backendVersion == null ? '' : ' | Runtime: $backendVersion';
2128
+ return 'Profile: $deploymentProfileLabel | Channel: $releaseChannelLabel$branch$npm | Update Version: $updateVersion$installed$backend';
2129
+ }
2130
+
2131
+ String get logsText =>
2132
+ logs.isEmpty ? 'Waiting for update job output…' : logs.join('\n');
2133
+ }
2134
+
2135
+ class LogEntry {
2136
+ const LogEntry({
2137
+ required this.type,
2138
+ required this.message,
2139
+ required this.timestamp,
2140
+ this.source = 'server',
2141
+ });
2142
+
2143
+ factory LogEntry.fromJson(Map<dynamic, dynamic> json) {
2144
+ return LogEntry(
2145
+ type: json['type']?.toString() ?? 'log',
2146
+ message: json['message']?.toString() ?? '',
2147
+ timestamp: _parseTimestamp(json['timestamp']?.toString()),
2148
+ source: json['source']?.toString().ifEmpty('server') ?? 'server',
2149
+ );
2150
+ }
2151
+
2152
+ final String type;
2153
+ final String message;
2154
+ final DateTime timestamp;
2155
+ final String source;
2156
+
2157
+ String get timeLabel => _formatTimeOnly(timestamp);
2158
+
2159
+ String get clipboardLine => '[$timeLabel][$source] $message';
2160
+
2161
+ Color get color {
2162
+ switch (type) {
2163
+ case 'error':
2164
+ return _danger;
2165
+ case 'warn':
2166
+ return _warning;
2167
+ case 'info':
2168
+ return _info;
2169
+ default:
2170
+ return _textPrimary;
2171
+ }
2172
+ }
2173
+
2174
+ String get sourceLabel {
2175
+ switch (source) {
2176
+ case 'flutter':
2177
+ return 'Flutter';
2178
+ case 'server':
2179
+ default:
2180
+ return 'Server';
2181
+ }
2182
+ }
2183
+ }
2184
+
2185
+ class SkillItem {
2186
+ const SkillItem({
2187
+ required this.name,
2188
+ required this.description,
2189
+ required this.enabled,
2190
+ required this.draft,
2191
+ required this.category,
2192
+ required this.source,
2193
+ });
2194
+
2195
+ factory SkillItem.fromJson(Map<dynamic, dynamic> json) {
2196
+ return SkillItem(
2197
+ name: json['name']?.toString() ?? 'Skill',
2198
+ description: json['description']?.toString() ?? '',
2199
+ enabled: json['enabled'] != false,
2200
+ draft: json['draft'] == true,
2201
+ category: json['category']?.toString().ifEmpty('general') ?? 'general',
2202
+ source: json['source']?.toString().ifEmpty('local') ?? 'local',
2203
+ );
2204
+ }
2205
+
2206
+ final String name;
2207
+ final String description;
2208
+ final bool enabled;
2209
+ final bool draft;
2210
+ final String category;
2211
+ final String source;
2212
+ }
2213
+
2214
+ class StoreSkillItem {
2215
+ const StoreSkillItem({
2216
+ required this.id,
2217
+ required this.name,
2218
+ required this.description,
2219
+ required this.category,
2220
+ required this.icon,
2221
+ required this.installed,
2222
+ });
2223
+
2224
+ factory StoreSkillItem.fromJson(Map<dynamic, dynamic> json) {
2225
+ return StoreSkillItem(
2226
+ id: json['id']?.toString() ?? '',
2227
+ name: json['name']?.toString() ?? 'Skill',
2228
+ description: json['description']?.toString() ?? '',
2229
+ category: json['category']?.toString().ifEmpty('general') ?? 'general',
2230
+ icon: json['icon']?.toString().ifEmpty('🧩') ?? '🧩',
2231
+ installed: json['installed'] == true,
2232
+ );
2233
+ }
2234
+
2235
+ final String id;
2236
+ final String name;
2237
+ final String description;
2238
+ final String category;
2239
+ final String icon;
2240
+ final bool installed;
2241
+ }
2242
+
2243
+ class OfficialIntegrationAppItem {
2244
+ const OfficialIntegrationAppItem({
2245
+ required this.id,
2246
+ required this.label,
2247
+ this.description,
2248
+ this.connection = const OfficialIntegrationConnectionStatus(
2249
+ status: 'not_connected',
2250
+ connected: false,
2251
+ ),
2252
+ this.accounts = const <OfficialIntegrationAccountItem>[],
2253
+ this.availableToolCount = 0,
2254
+ });
2255
+
2256
+ factory OfficialIntegrationAppItem.fromJson(Map<dynamic, dynamic> json) {
2257
+ final accountsRaw = json['accounts'];
2258
+ return OfficialIntegrationAppItem(
2259
+ id: json['id']?.toString() ?? '',
2260
+ label: json['label']?.toString() ?? 'App',
2261
+ description: json['description']?.toString(),
2262
+ connection: OfficialIntegrationConnectionStatus.fromJson(
2263
+ _jsonMap(json['connection']),
2264
+ ),
2265
+ accounts: accountsRaw is List
2266
+ ? accountsRaw
2267
+ .whereType<Map<dynamic, dynamic>>()
2268
+ .map(OfficialIntegrationAccountItem.fromJson)
2269
+ .toList()
2270
+ : const <OfficialIntegrationAccountItem>[],
2271
+ availableToolCount: _asInt(json['availableToolCount']),
2272
+ );
2273
+ }
2274
+
2275
+ final String id;
2276
+ final String label;
2277
+ final String? description;
2278
+ final OfficialIntegrationConnectionStatus connection;
2279
+ final List<OfficialIntegrationAccountItem> accounts;
2280
+ final int availableToolCount;
2281
+
2282
+ bool get isConnected => connection.connected;
2283
+
2284
+ bool get hasExpiredAccounts =>
2285
+ accounts.any((account) => account.isExpired && !account.connected);
2286
+
2287
+ String get effectiveStatus =>
2288
+ !isConnected && hasExpiredAccounts ? 'expired' : connection.status;
2289
+
2290
+ String get statusLabel => _titleCase(effectiveStatus.replaceAll('_', ' '));
2291
+ }
2292
+
2293
+ class OfficialIntegrationEnvStatus {
2294
+ const OfficialIntegrationEnvStatus({
2295
+ required this.configured,
2296
+ required this.missing,
2297
+ required this.summary,
2298
+ this.setupMode,
2299
+ });
2300
+
2301
+ factory OfficialIntegrationEnvStatus.fromJson(Map<dynamic, dynamic> json) {
2302
+ final missingRaw = json['missing'];
2303
+ return OfficialIntegrationEnvStatus(
2304
+ configured: json['configured'] == true,
2305
+ missing: missingRaw is List
2306
+ ? missingRaw.map((item) => item.toString()).toList()
2307
+ : const <String>[],
2308
+ summary: json['summary']?.toString() ?? '',
2309
+ setupMode: json['setupMode']?.toString(),
2310
+ );
2311
+ }
2312
+
2313
+ final bool configured;
2314
+ final List<String> missing;
2315
+ final String summary;
2316
+ final String? setupMode;
2317
+ }
2318
+
2319
+ class OfficialIntegrationConnectionStatus {
2320
+ const OfficialIntegrationConnectionStatus({
2321
+ required this.status,
2322
+ required this.connected,
2323
+ this.accountEmail,
2324
+ this.lastConnectedAt,
2325
+ this.accountCount = 0,
2326
+ this.appCount = 0,
2327
+ });
2328
+
2329
+ factory OfficialIntegrationConnectionStatus.fromJson(
2330
+ Map<dynamic, dynamic> json,
2331
+ ) {
2332
+ return OfficialIntegrationConnectionStatus(
2333
+ status: json['status']?.toString() ?? 'not_connected',
2334
+ connected: json['connected'] == true,
2335
+ accountEmail: json['accountEmail']?.toString(),
2336
+ lastConnectedAt: _parseOptionalTimestamp(
2337
+ json['lastConnectedAt']?.toString(),
2338
+ ),
2339
+ accountCount: _asInt(json['accountCount']),
2340
+ appCount: _asInt(json['appCount']),
2341
+ );
2342
+ }
2343
+
2344
+ final String status;
2345
+ final bool connected;
2346
+ final String? accountEmail;
2347
+ final DateTime? lastConnectedAt;
2348
+ final int accountCount;
2349
+ final int appCount;
2350
+
2351
+ String get statusLabel {
2352
+ switch (status) {
2353
+ case 'env_not_configured':
2354
+ return 'Setup Required';
2355
+ case 'not_connected':
2356
+ return 'Not Connected';
2357
+ case 'expired':
2358
+ return 'Expired';
2359
+ default:
2360
+ return _titleCase(status.replaceAll('_', ' '));
2361
+ }
2362
+ }
2363
+ }
2364
+
2365
+ class OfficialIntegrationAccountItem {
2366
+ const OfficialIntegrationAccountItem({
2367
+ required this.id,
2368
+ required this.status,
2369
+ required this.connected,
2370
+ this.accountEmail,
2371
+ this.lastConnectedAt,
2372
+ this.accessMode = 'read_write',
2373
+ });
2374
+
2375
+ factory OfficialIntegrationAccountItem.fromJson(Map<dynamic, dynamic> json) {
2376
+ return OfficialIntegrationAccountItem(
2377
+ id: _asInt(json['id']),
2378
+ status: json['status']?.toString() ?? 'not_connected',
2379
+ connected: json['connected'] == true,
2380
+ accountEmail: json['accountEmail']?.toString(),
2381
+ lastConnectedAt: _parseOptionalTimestamp(
2382
+ json['lastConnectedAt']?.toString(),
2383
+ ),
2384
+ accessMode: json['accessMode']?.toString() ?? 'read_write',
2385
+ );
2386
+ }
2387
+
2388
+ final int id;
2389
+ final String status;
2390
+ final bool connected;
2391
+ final String? accountEmail;
2392
+ final DateTime? lastConnectedAt;
2393
+ final String accessMode;
2394
+
2395
+ bool get isExpired => status == 'expired';
2396
+
2397
+ String get statusLabel => _titleCase(status.replaceAll('_', ' '));
2398
+
2399
+ String get accessModeLabel {
2400
+ switch (accessMode) {
2401
+ case 'read_only':
2402
+ return 'Read Only';
2403
+ default:
2404
+ return 'Read / Write';
2405
+ }
2406
+ }
2407
+ }
2408
+
2409
+ class OfficialIntegrationItem {
2410
+ const OfficialIntegrationItem({
2411
+ required this.id,
2412
+ required this.label,
2413
+ required this.description,
2414
+ required this.icon,
2415
+ required this.apps,
2416
+ required this.env,
2417
+ required this.connection,
2418
+ required this.availableToolCount,
2419
+ this.connectPrompt,
2420
+ });
2421
+
2422
+ factory OfficialIntegrationItem.fromJson(Map<dynamic, dynamic> json) {
2423
+ final appsRaw = json['apps'];
2424
+ return OfficialIntegrationItem(
2425
+ id: json['id']?.toString() ?? '',
2426
+ label: json['label']?.toString() ?? 'Integration',
2427
+ description: json['description']?.toString() ?? '',
2428
+ icon: json['icon']?.toString() ?? '',
2429
+ apps: appsRaw is List
2430
+ ? appsRaw
2431
+ .whereType<Map<dynamic, dynamic>>()
2432
+ .map(OfficialIntegrationAppItem.fromJson)
2433
+ .toList()
2434
+ : const <OfficialIntegrationAppItem>[],
2435
+ env: OfficialIntegrationEnvStatus.fromJson(_jsonMap(json['env'])),
2436
+ connection: OfficialIntegrationConnectionStatus.fromJson(
2437
+ _jsonMap(json['connection']),
2438
+ ),
2439
+ availableToolCount: _asInt(json['availableToolCount']),
2440
+ connectPrompt: json['connectPrompt']?.toString(),
2441
+ );
2442
+ }
2443
+
2444
+ final String id;
2445
+ final String label;
2446
+ final String description;
2447
+ final String icon;
2448
+ final List<OfficialIntegrationAppItem> apps;
2449
+ final OfficialIntegrationEnvStatus env;
2450
+ final OfficialIntegrationConnectionStatus connection;
2451
+ final int availableToolCount;
2452
+ final String? connectPrompt;
2453
+
2454
+ bool get isConnected => connection.connected;
2455
+
2456
+ bool get hasExpiredAccounts => apps.any((app) => app.hasExpiredAccounts);
2457
+
2458
+ String get effectiveStatus =>
2459
+ !isConnected && hasExpiredAccounts ? 'expired' : connection.status;
2460
+
2461
+ String get statusLabel => _titleCase(effectiveStatus.replaceAll('_', ' '));
2462
+ }
2463
+
2464
+ class SkillDocument {
2465
+ const SkillDocument({required this.name, required this.content});
2466
+
2467
+ factory SkillDocument.fromJson(Map<dynamic, dynamic> json) {
2468
+ return SkillDocument(
2469
+ name: json['name']?.toString() ?? 'Skill',
2470
+ content: json['content']?.toString() ?? '',
2471
+ );
2472
+ }
2473
+
2474
+ final String name;
2475
+ final String content;
2476
+ }
2477
+
2478
+ class MemoryOverview {
2479
+ const MemoryOverview({
2480
+ this.assistantBehaviorNotes = '',
2481
+ this.dailyLogs = const <String>[],
2482
+ this.apiKeys = const <String, String>{},
2483
+ this.coreEntries = const <String, dynamic>{},
2484
+ });
2485
+
2486
+ factory MemoryOverview.fromJson(Map<dynamic, dynamic> json) {
2487
+ final apiKeysRaw = json['apiKeys'];
2488
+ final coreRaw = json['coreMemory'];
2489
+ return MemoryOverview(
2490
+ assistantBehaviorNotes: json['assistantBehaviorNotes']?.toString() ?? '',
2491
+ dailyLogs: _jsonStringList(json['dailyLogs'], fallbackToMapValues: true),
2492
+ apiKeys: apiKeysRaw is Map
2493
+ ? Map<String, String>.from(
2494
+ apiKeysRaw.map(
2495
+ (key, value) =>
2496
+ MapEntry(key.toString(), value?.toString() ?? ''),
2497
+ ),
2498
+ )
2499
+ : const <String, String>{},
2500
+ coreEntries: coreRaw is Map
2501
+ ? Map<String, dynamic>.from(coreRaw)
2502
+ : const <String, dynamic>{},
2503
+ );
2504
+ }
2505
+
2506
+ final String assistantBehaviorNotes;
2507
+ final List<String> dailyLogs;
2508
+ final Map<String, String> apiKeys;
2509
+ final Map<String, dynamic> coreEntries;
2510
+
2511
+ int get behaviorNotesLength => assistantBehaviorNotes.length;
2512
+ int get dailyLogCount => dailyLogs.length;
2513
+ int get apiKeyCount => apiKeys.length;
2514
+ int get coreCount => coreEntries.length;
2515
+ }
2516
+
2517
+ class MemoryItem {
2518
+ const MemoryItem({
2519
+ required this.id,
2520
+ required this.content,
2521
+ required this.category,
2522
+ required this.importance,
2523
+ required this.createdAt,
2524
+ });
2525
+
2526
+ factory MemoryItem.fromJson(Map<dynamic, dynamic> json) {
2527
+ return MemoryItem(
2528
+ id: json['id']?.toString() ?? '',
2529
+ content: json['content']?.toString() ?? '',
2530
+ category: json['category']?.toString().ifEmpty('memory') ?? 'memory',
2531
+ importance: _asInt(json['importance']),
2532
+ createdAt: _parseTimestamp(json['created_at']?.toString()),
2533
+ );
2534
+ }
2535
+
2536
+ final String id;
2537
+ final String content;
2538
+ final String category;
2539
+ final int importance;
2540
+ final DateTime createdAt;
2541
+
2542
+ String get createdAtLabel => _formatTimestamp(createdAt);
2543
+ }
2544
+
2545
+ class ConversationItem {
2546
+ const ConversationItem({required this.title, required this.preview});
2547
+
2548
+ factory ConversationItem.fromJson(Map<dynamic, dynamic> json) {
2549
+ final raw =
2550
+ json['summary']?.toString().ifEmpty(
2551
+ json['content']?.toString() ?? '',
2552
+ ) ??
2553
+ '';
2554
+ return ConversationItem(
2555
+ title:
2556
+ json['title']?.toString().ifEmpty('Conversation') ?? 'Conversation',
2557
+ preview: raw.ifEmpty('No summary available.'),
2558
+ );
2559
+ }
2560
+
2561
+ final String title;
2562
+ final String preview;
2563
+ }
2564
+
2565
+ class TaskItem {
2566
+ const TaskItem({
2567
+ required this.id,
2568
+ required this.agentId,
2569
+ required this.name,
2570
+ required this.triggerType,
2571
+ required this.triggerSummary,
2572
+ required this.triggerConfig,
2573
+ required this.nextRun,
2574
+ required this.prompt,
2575
+ required this.model,
2576
+ required this.enabled,
2577
+ required this.lastRun,
2578
+ required this.taskType,
2579
+ required this.widgetId,
2580
+ });
2581
+
2582
+ factory TaskItem.fromJson(Map<dynamic, dynamic> json) {
2583
+ final legacyConfig = json['config'] is Map
2584
+ ? Map<String, dynamic>.from(json['config'] as Map)
2585
+ : const <String, dynamic>{};
2586
+ final taskConfig = {
2587
+ ...legacyConfig,
2588
+ ...(json['taskConfig'] is Map
2589
+ ? Map<String, dynamic>.from(json['taskConfig'] as Map)
2590
+ : const <String, dynamic>{}),
2591
+ };
2592
+ final triggerConfig = {
2593
+ ...(legacyConfig['triggerConfig'] is Map
2594
+ ? Map<String, dynamic>.from(legacyConfig['triggerConfig'] as Map)
2595
+ : const <String, dynamic>{}),
2596
+ ...(json['triggerConfig'] is Map
2597
+ ? Map<String, dynamic>.from(json['triggerConfig'] as Map)
2598
+ : const <String, dynamic>{}),
2599
+ };
2600
+ final triggerSummary = json['triggerSummary']?.toString() ?? '';
2601
+ return TaskItem(
2602
+ id: _asInt(json['id']),
2603
+ agentId: json['agentId']?.toString() ?? json['agent_id']?.toString(),
2604
+ name: json['name']?.toString() ?? 'Task',
2605
+ triggerType: json['triggerType']?.toString() ?? 'schedule',
2606
+ triggerSummary: triggerSummary.trim().isEmpty
2607
+ ? 'Task trigger'
2608
+ : triggerSummary,
2609
+ triggerConfig: triggerConfig,
2610
+ nextRun: _parseOptionalTimestamp(json['nextRun']?.toString()),
2611
+ prompt:
2612
+ json['prompt']?.toString().ifEmpty(
2613
+ taskConfig['prompt']?.toString() ?? '',
2614
+ ) ??
2615
+ '',
2616
+ model:
2617
+ json['model']?.toString().ifEmpty(
2618
+ taskConfig['model']?.toString() ?? '',
2619
+ ) ??
2620
+ '',
2621
+ enabled: json['enabled'] != false,
2622
+ lastRun: _parseOptionalTimestamp(json['lastRun']?.toString()),
2623
+ taskType:
2624
+ json['taskType']?.toString().ifEmpty(
2625
+ json['task_type']?.toString() ?? 'agent_prompt',
2626
+ ) ??
2627
+ 'agent_prompt',
2628
+ widgetId:
2629
+ json['widgetId']?.toString().ifEmpty(
2630
+ taskConfig['widgetId']?.toString() ?? '',
2631
+ ) ??
2632
+ '',
2633
+ );
2634
+ }
2635
+
2636
+ final int id;
2637
+ final String? agentId;
2638
+ final String name;
2639
+ final String triggerType;
2640
+ final String triggerSummary;
2641
+ final Map<String, dynamic> triggerConfig;
2642
+ final DateTime? nextRun;
2643
+ final String prompt;
2644
+ final String model;
2645
+ final bool enabled;
2646
+ final DateTime? lastRun;
2647
+ final String taskType;
2648
+ final String widgetId;
2649
+
2650
+ String get scheduleLabel =>
2651
+ triggerSummary.trim().isEmpty ? 'Task trigger' : triggerSummary;
2652
+ String get lastRunLabel => lastRun == null ? '' : _formatTimestamp(lastRun!);
2653
+ bool get hasModelOverride => model.trim().isNotEmpty;
2654
+ bool get isWidgetRefresh => taskType == 'widget_refresh';
2655
+ }
2656
+
2657
+ class WidgetSnapshotItem {
2658
+ const WidgetSnapshotItem({
2659
+ required this.id,
2660
+ required this.widgetId,
2661
+ required this.payload,
2662
+ required this.generatedAt,
2663
+ required this.sourceRunId,
2664
+ required this.status,
2665
+ });
2666
+
2667
+ factory WidgetSnapshotItem.fromJson(Map<dynamic, dynamic> json) {
2668
+ return WidgetSnapshotItem(
2669
+ id: _asInt(json['id']),
2670
+ widgetId:
2671
+ json['widgetId']?.toString() ?? json['widget_id']?.toString() ?? '',
2672
+ payload: json['payload'] is Map
2673
+ ? Map<String, dynamic>.from(json['payload'] as Map)
2674
+ : const <String, dynamic>{},
2675
+ generatedAt: _parseOptionalTimestamp(
2676
+ json['generatedAt']?.toString() ?? json['generated_at']?.toString(),
2677
+ ),
2678
+ sourceRunId:
2679
+ json['sourceRunId']?.toString() ?? json['source_run_id']?.toString(),
2680
+ status: json['status']?.toString().ifEmpty('ready') ?? 'ready',
2681
+ );
2682
+ }
2683
+
2684
+ final int id;
2685
+ final String widgetId;
2686
+ final Map<String, dynamic> payload;
2687
+ final DateTime? generatedAt;
2688
+ final String? sourceRunId;
2689
+ final String status;
2690
+
2691
+ String get title =>
2692
+ payload['title']?.toString().ifEmpty('Untitled widget') ??
2693
+ 'Untitled widget';
2694
+ String get kicker => payload['kicker']?.toString() ?? '';
2695
+ String get subtitle => payload['subtitle']?.toString() ?? '';
2696
+ String get body => payload['body']?.toString() ?? '';
2697
+ String get metric => payload['metric']?.toString() ?? '';
2698
+ String get metricLabel => payload['metricLabel']?.toString() ?? '';
2699
+ String get secondaryMetric => payload['secondaryMetric']?.toString() ?? '';
2700
+ String get secondaryLabel => payload['secondaryLabel']?.toString() ?? '';
2701
+ String get tertiaryMetric => payload['tertiaryMetric']?.toString() ?? '';
2702
+ String get tertiaryLabel => payload['tertiaryLabel']?.toString() ?? '';
2703
+ String get template => payload['template']?.toString() ?? '';
2704
+ String get layoutVariant => payload['layoutVariant']?.toString() ?? '';
2705
+ String get deepLink => payload['deepLink']?.toString() ?? '';
2706
+ String get iconToken => payload['iconToken']?.toString() ?? '';
2707
+ String get accentToken => payload['accentToken']?.toString() ?? '';
2708
+ String get backgroundToken => payload['backgroundToken']?.toString() ?? '';
2709
+ String get surfaceColor => payload['surfaceColor']?.toString() ?? '';
2710
+
2711
+ Map<String, dynamic>? get trend {
2712
+ final raw = payload['trend'];
2713
+ return raw is Map ? Map<String, dynamic>.from(raw) : null;
2714
+ }
2715
+
2716
+ Map<String, dynamic>? get progress {
2717
+ final raw = payload['progress'];
2718
+ return raw is Map ? Map<String, dynamic>.from(raw) : null;
2719
+ }
2720
+
2721
+ List<Map<String, dynamic>> get rows => _jsonMapList(payload['rows']);
2722
+ List<String> get chips =>
2723
+ (payload['chips'] as List?)
2724
+ ?.map((chip) => chip?.toString() ?? '')
2725
+ .where((chip) => chip.trim().isNotEmpty)
2726
+ .toList(growable: false) ??
2727
+ const <String>[];
2728
+
2729
+ String get generatedAtLabel =>
2730
+ generatedAt == null ? 'No refresh yet' : _formatTimestamp(generatedAt!);
2731
+ }
2732
+
2733
+ class AiWidgetItem {
2734
+ const AiWidgetItem({
2735
+ required this.id,
2736
+ required this.userId,
2737
+ required this.agentId,
2738
+ required this.name,
2739
+ required this.template,
2740
+ required this.layoutVariant,
2741
+ required this.definition,
2742
+ required this.refreshCron,
2743
+ required this.enabled,
2744
+ required this.scheduledTaskId,
2745
+ required this.lastSnapshotAt,
2746
+ required this.lastError,
2747
+ required this.createdAt,
2748
+ required this.updatedAt,
2749
+ required this.nextRefresh,
2750
+ required this.latestSnapshot,
2751
+ required this.tasks,
2752
+ });
2753
+
2754
+ factory AiWidgetItem.fromJson(Map<dynamic, dynamic> json) {
2755
+ return AiWidgetItem(
2756
+ id: json['id']?.toString() ?? '',
2757
+ userId: _asInt(json['userId'] ?? json['user_id']),
2758
+ agentId: json['agentId']?.toString() ?? json['agent_id']?.toString(),
2759
+ name: json['name']?.toString().ifEmpty('Widget') ?? 'Widget',
2760
+ template: json['template']?.toString().ifEmpty('summary') ?? 'summary',
2761
+ layoutVariant:
2762
+ json['layoutVariant']?.toString().ifEmpty(
2763
+ json['layout_variant']?.toString() ?? 'stack',
2764
+ ) ??
2765
+ 'stack',
2766
+ definition: json['definition'] is Map
2767
+ ? Map<String, dynamic>.from(json['definition'] as Map)
2768
+ : (json['definition_json'] is Map
2769
+ ? Map<String, dynamic>.from(json['definition_json'] as Map)
2770
+ : const <String, dynamic>{}),
2771
+ refreshCron:
2772
+ json['refreshCron']?.toString().ifEmpty(
2773
+ json['refresh_cron']?.toString() ?? '',
2774
+ ) ??
2775
+ '',
2776
+ enabled: json['enabled'] != false,
2777
+ scheduledTaskId: _asInt(
2778
+ json['scheduledTaskId'] ?? json['scheduled_task_id'],
2779
+ ),
2780
+ lastSnapshotAt: _parseOptionalTimestamp(
2781
+ json['lastSnapshotAt']?.toString() ??
2782
+ json['last_snapshot_at']?.toString(),
2783
+ ),
2784
+ lastError:
2785
+ json['lastError']?.toString() ?? json['last_error']?.toString(),
2786
+ createdAt: _parseOptionalTimestamp(
2787
+ json['createdAt']?.toString() ?? json['created_at']?.toString(),
2788
+ ),
2789
+ updatedAt: _parseOptionalTimestamp(
2790
+ json['updatedAt']?.toString() ?? json['updated_at']?.toString(),
2791
+ ),
2792
+ nextRefresh: _parseOptionalTimestamp(
2793
+ json['nextRefresh']?.toString() ?? json['next_refresh']?.toString(),
2794
+ ),
2795
+ latestSnapshot: json['latestSnapshot'] is Map
2796
+ ? WidgetSnapshotItem.fromJson(
2797
+ Map<String, dynamic>.from(json['latestSnapshot'] as Map),
2798
+ )
2799
+ : null,
2800
+ tasks: json['tasks'] is List
2801
+ ? (json['tasks'] as List)
2802
+ .whereType<Map<dynamic, dynamic>>()
2803
+ .map((m) => TaskItem.fromJson(m))
2804
+ .toList()
2805
+ : const <TaskItem>[],
2806
+ );
2807
+ }
2808
+
2809
+ final String id;
2810
+ final int userId;
2811
+ final String? agentId;
2812
+ final String name;
2813
+ final String template;
2814
+ final String layoutVariant;
2815
+ final Map<String, dynamic> definition;
2816
+ final String refreshCron;
2817
+ final bool enabled;
2818
+ final int scheduledTaskId;
2819
+ final DateTime? lastSnapshotAt;
2820
+ final String? lastError;
2821
+ final DateTime? createdAt;
2822
+ final DateTime? updatedAt;
2823
+ final DateTime? nextRefresh;
2824
+ final WidgetSnapshotItem? latestSnapshot;
2825
+ final List<TaskItem> tasks;
2826
+
2827
+ bool get hasSnapshot => latestSnapshot != null;
2828
+ bool get hasError => (lastError ?? '').trim().isNotEmpty;
2829
+ String get prompt => definition['prompt']?.toString() ?? '';
2830
+ String get nextRefreshLabel => nextRefresh == null
2831
+ ? 'Next refresh unknown'
2832
+ : _formatTimestamp(nextRefresh!);
2833
+ String get lastSnapshotLabel => lastSnapshotAt == null
2834
+ ? 'No snapshot yet'
2835
+ : _formatTimestamp(lastSnapshotAt!);
2836
+ }
2837
+
2838
+ class McpServerItem {
2839
+ const McpServerItem({
2840
+ required this.id,
2841
+ required this.agentId,
2842
+ required this.name,
2843
+ required this.command,
2844
+ required this.config,
2845
+ required this.enabled,
2846
+ required this.status,
2847
+ required this.toolCount,
2848
+ });
2849
+
2850
+ factory McpServerItem.fromJson(Map<dynamic, dynamic> json) {
2851
+ return McpServerItem(
2852
+ id: _asInt(json['id']),
2853
+ agentId: json['agentId']?.toString() ?? json['agent_id']?.toString(),
2854
+ name: json['name']?.toString() ?? 'MCP Server',
2855
+ command: json['command']?.toString() ?? '',
2856
+ config: json['config'] is Map
2857
+ ? Map<String, dynamic>.from(json['config'] as Map)
2858
+ : const <String, dynamic>{},
2859
+ enabled: json['enabled'] == true,
2860
+ status: json['status']?.toString().ifEmpty('stopped') ?? 'stopped',
2861
+ toolCount: _asInt(json['toolCount']),
2862
+ );
2863
+ }
2864
+
2865
+ final int id;
2866
+ final String? agentId;
2867
+ final String name;
2868
+ final String command;
2869
+ final Map<String, dynamic> config;
2870
+ final bool enabled;
2871
+ final String status;
2872
+ final int toolCount;
2873
+
2874
+ String get authMethodLabel {
2875
+ final auth = _jsonMap(config['auth']);
2876
+ final type = auth['type']?.toString().ifEmpty('none') ?? 'none';
2877
+ switch (type) {
2878
+ case 'bearer':
2879
+ return 'Bearer token';
2880
+ case 'oauth':
2881
+ return 'OAuth';
2882
+ default:
2883
+ return 'No auth';
2884
+ }
2885
+ }
2886
+ }
2887
+
2888
+ class AccountSessionItem {
2889
+ const AccountSessionItem({
2890
+ required this.id,
2891
+ required this.current,
2892
+ required this.ipAddress,
2893
+ required this.userAgent,
2894
+ required this.location,
2895
+ required this.createdAt,
2896
+ required this.lastSeenAt,
2897
+ required this.expiresAt,
2898
+ });
2899
+
2900
+ factory AccountSessionItem.fromJson(Map<dynamic, dynamic> json) {
2901
+ return AccountSessionItem(
2902
+ id: _asInt(json['id']),
2903
+ current: json['current'] == true,
2904
+ ipAddress: json['ipAddress']?.toString() ?? '',
2905
+ userAgent: json['userAgent']?.toString() ?? '',
2906
+ location: json['location']?.toString().ifEmpty('Unknown') ?? 'Unknown',
2907
+ createdAt: _parseOptionalTimestamp(json['createdAt']?.toString()),
2908
+ lastSeenAt: _parseOptionalTimestamp(json['lastSeenAt']?.toString()),
2909
+ expiresAt: _parseOptionalTimestamp(json['expiresAt']?.toString()),
2910
+ );
2911
+ }
2912
+
2913
+ final int id;
2914
+ final bool current;
2915
+ final String ipAddress;
2916
+ final String userAgent;
2917
+ final String location;
2918
+ final DateTime? createdAt;
2919
+ final DateTime? lastSeenAt;
2920
+ final DateTime? expiresAt;
2921
+
2922
+ _SessionClientInfo get _clientInfo => _SessionClientInfo.parse(userAgent);
2923
+
2924
+ IconData get deviceIcon => switch (_clientInfo.deviceClass) {
2925
+ _SessionDeviceClass.mobile => Icons.smartphone_rounded,
2926
+ _SessionDeviceClass.tablet => Icons.tablet_mac_rounded,
2927
+ _SessionDeviceClass.desktop => Icons.laptop_mac_rounded,
2928
+ _SessionDeviceClass.server => Icons.dns_outlined,
2929
+ _SessionDeviceClass.unknown => Icons.devices_other_rounded,
2930
+ };
2931
+
2932
+ String get clientPlatformLabel => _clientInfo.platformLabel;
2933
+
2934
+ String get clientBrowserLabel => _clientInfo.browserLabel;
2935
+
2936
+ String get clientLabel {
2937
+ final parts = <String>[
2938
+ clientPlatformLabel,
2939
+ if (clientBrowserLabel.isNotEmpty &&
2940
+ clientBrowserLabel != 'Unknown browser')
2941
+ clientBrowserLabel,
2942
+ ];
2943
+ return parts.join(' · ').ifEmpty('Unknown device');
2944
+ }
2945
+
2946
+ String get locationSummary {
2947
+ final parts = <String>[
2948
+ if (location.trim().isNotEmpty) location.trim(),
2949
+ if (ipAddress.trim().isNotEmpty) ipAddress.trim(),
2950
+ ];
2951
+ return parts.join(' · ').ifEmpty('Unknown location');
2952
+ }
2953
+
2954
+ String get lastSeenLabel =>
2955
+ lastSeenAt == null ? 'Not recorded' : _formatTimestamp(lastSeenAt!);
2956
+ String get createdLabel =>
2957
+ createdAt == null ? 'Not recorded' : _formatTimestamp(createdAt!);
2958
+ String get expiresLabel =>
2959
+ expiresAt == null ? 'Session cookie' : _formatTimestamp(expiresAt!);
2960
+ }
2961
+
2962
+ enum _SessionDeviceClass { desktop, mobile, tablet, server, unknown }
2963
+
2964
+ class _SessionClientInfo {
2965
+ const _SessionClientInfo({
2966
+ required this.platformLabel,
2967
+ required this.browserLabel,
2968
+ required this.deviceClass,
2969
+ });
2970
+
2971
+ factory _SessionClientInfo.parse(String userAgent) {
2972
+ final raw = userAgent.trim();
2973
+ if (raw.isEmpty) {
2974
+ return const _SessionClientInfo(
2975
+ platformLabel: 'Unknown device',
2976
+ browserLabel: 'Unknown browser',
2977
+ deviceClass: _SessionDeviceClass.unknown,
2978
+ );
2979
+ }
2980
+
2981
+ final lower = raw.toLowerCase();
2982
+ final isTablet = lower.contains('ipad') || lower.contains('tablet');
2983
+ final isMobile =
2984
+ !isTablet &&
2985
+ (lower.contains('iphone') ||
2986
+ lower.contains('android') && lower.contains('mobile'));
2987
+
2988
+ final platformLabel = switch (true) {
2989
+ _ when lower.contains('iphone') => 'iPhone',
2990
+ _ when lower.contains('ipad') => 'iPad',
2991
+ _ when lower.contains('android') => 'Android',
2992
+ _ when lower.contains('mac os x') || lower.contains('macintosh') =>
2993
+ 'macOS',
2994
+ _ when lower.contains('windows nt') => 'Windows',
2995
+ _ when lower.contains('linux') => 'Linux',
2996
+ _ when lower.contains('x11') => 'Linux',
2997
+ _
2998
+ when lower.contains('curl/') ||
2999
+ lower.contains('wget/') ||
3000
+ lower.contains('httpie/') =>
3001
+ 'CLI session',
3002
+ _ => 'Unknown device',
3003
+ };
3004
+
3005
+ final browserLabel = switch (true) {
3006
+ _ when lower.contains('edg/') => 'Edge',
3007
+ _ when lower.contains('opr/') || lower.contains('opera/') => 'Opera',
3008
+ _ when lower.contains('brave/') => 'Brave',
3009
+ _ when lower.contains('firefox/') => 'Firefox',
3010
+ _
3011
+ when lower.contains('chrome/') ||
3012
+ lower.contains('crios/') ||
3013
+ lower.contains('chromium/') =>
3014
+ 'Chrome',
3015
+ _ when lower.contains('safari/') && lower.contains('version/') =>
3016
+ 'Safari',
3017
+ _ when lower.contains('curl/') => 'curl',
3018
+ _ when lower.contains('wget/') => 'wget',
3019
+ _ when lower.contains('httpie/') => 'HTTPie',
3020
+ _ => 'Unknown browser',
3021
+ };
3022
+
3023
+ final deviceClass = switch (true) {
3024
+ _ when platformLabel == 'CLI session' => _SessionDeviceClass.server,
3025
+ _ when isTablet => _SessionDeviceClass.tablet,
3026
+ _ when isMobile => _SessionDeviceClass.mobile,
3027
+ _
3028
+ when platformLabel == 'macOS' ||
3029
+ platformLabel == 'Windows' ||
3030
+ platformLabel == 'Linux' =>
3031
+ _SessionDeviceClass.desktop,
3032
+ _ => _SessionDeviceClass.unknown,
3033
+ };
3034
+
3035
+ return _SessionClientInfo(
3036
+ platformLabel: platformLabel,
3037
+ browserLabel: browserLabel,
3038
+ deviceClass: deviceClass,
3039
+ );
3040
+ }
3041
+
3042
+ final String platformLabel;
3043
+ final String browserLabel;
3044
+ final _SessionDeviceClass deviceClass;
3045
+ }
3046
+
3047
+ class AuthProviderCatalogItem {
3048
+ const AuthProviderCatalogItem({
3049
+ required this.id,
3050
+ required this.label,
3051
+ required this.icon,
3052
+ required this.configured,
3053
+ required this.summary,
3054
+ });
3055
+
3056
+ factory AuthProviderCatalogItem.fromJson(Map<dynamic, dynamic> json) {
3057
+ return AuthProviderCatalogItem(
3058
+ id: json['id']?.toString() ?? '',
3059
+ label: json['label']?.toString() ?? '',
3060
+ icon: json['icon']?.toString() ?? '',
3061
+ configured: json['configured'] == true,
3062
+ summary: json['summary']?.toString() ?? '',
3063
+ );
3064
+ }
3065
+
3066
+ final String id;
3067
+ final String label;
3068
+ final String icon;
3069
+ final bool configured;
3070
+ final String summary;
3071
+ }
3072
+
3073
+ class LinkedAuthProviderItem {
3074
+ const LinkedAuthProviderItem({
3075
+ required this.id,
3076
+ required this.provider,
3077
+ required this.label,
3078
+ required this.icon,
3079
+ required this.email,
3080
+ required this.lastUsedAt,
3081
+ required this.linkedAt,
3082
+ required this.canUnlink,
3083
+ required this.metadata,
3084
+ });
3085
+
3086
+ factory LinkedAuthProviderItem.fromJson(Map<dynamic, dynamic> json) {
3087
+ return LinkedAuthProviderItem(
3088
+ id: _asInt(json['id']),
3089
+ provider: json['provider']?.toString() ?? '',
3090
+ label: json['label']?.toString() ?? '',
3091
+ icon: json['icon']?.toString() ?? '',
3092
+ email: json['email']?.toString() ?? '',
3093
+ lastUsedAt: _parseOptionalTimestamp(json['lastUsedAt']?.toString()),
3094
+ linkedAt: _parseOptionalTimestamp(json['linkedAt']?.toString()),
3095
+ canUnlink: json['canUnlink'] == true,
3096
+ metadata: json['metadata'] is Map
3097
+ ? Map<String, dynamic>.from(json['metadata'] as Map)
3098
+ : const <String, dynamic>{},
3099
+ );
3100
+ }
3101
+
3102
+ final int id;
3103
+ final String provider;
3104
+ final String label;
3105
+ final String icon;
3106
+ final String email;
3107
+ final DateTime? lastUsedAt;
3108
+ final DateTime? linkedAt;
3109
+ final bool canUnlink;
3110
+ final Map<String, dynamic> metadata;
3111
+
3112
+ String get avatarUrl => metadata['avatarUrl']?.toString() ?? '';
3113
+ String get displayName => metadata['displayName']?.toString() ?? '';
3114
+ String get linkedAtLabel =>
3115
+ linkedAt == null ? 'Linked recently' : _formatTimestamp(linkedAt!);
3116
+ String get lastUsedLabel =>
3117
+ lastUsedAt == null ? 'Not used yet' : _formatTimestamp(lastUsedAt!);
3118
+ }
3119
+
3120
+ class QrLoginChallenge {
3121
+ const QrLoginChallenge({
3122
+ required this.challengeId,
3123
+ required this.pollToken,
3124
+ required this.qrPayload,
3125
+ required this.backendUrl,
3126
+ required this.status,
3127
+ required this.expiresAt,
3128
+ });
3129
+
3130
+ factory QrLoginChallenge.fromJson(Map<dynamic, dynamic> json) {
3131
+ return QrLoginChallenge(
3132
+ challengeId: json['challengeId']?.toString() ?? '',
3133
+ pollToken: json['pollToken']?.toString() ?? '',
3134
+ qrPayload: json['qrPayload']?.toString() ?? '',
3135
+ backendUrl: json['backendUrl']?.toString() ?? '',
3136
+ status: json['status']?.toString().ifEmpty('pending') ?? 'pending',
3137
+ expiresAt: _parseOptionalTimestamp(json['expiresAt']?.toString()),
3138
+ );
3139
+ }
3140
+
3141
+ final String challengeId;
3142
+ final String pollToken;
3143
+ final String qrPayload;
3144
+ final String backendUrl;
3145
+ final String status;
3146
+ final DateTime? expiresAt;
3147
+
3148
+ bool get isUsable =>
3149
+ challengeId.isNotEmpty &&
3150
+ pollToken.isNotEmpty &&
3151
+ qrPayload.isNotEmpty &&
3152
+ status != 'expired';
3153
+
3154
+ bool get isExpired =>
3155
+ status == 'expired' ||
3156
+ (expiresAt != null && expiresAt!.isBefore(DateTime.now()));
3157
+
3158
+ int get secondsRemaining {
3159
+ final expires = expiresAt;
3160
+ if (expires == null) return 0;
3161
+ final diff = expires.difference(DateTime.now()).inSeconds;
3162
+ return diff < 0 ? 0 : diff;
3163
+ }
3164
+ }
3165
+
3166
+ class QrLoginApprovalPreview {
3167
+ const QrLoginApprovalPreview({
3168
+ required this.challengeId,
3169
+ required this.status,
3170
+ required this.requestedAt,
3171
+ required this.expiresAt,
3172
+ required this.approvedAt,
3173
+ required this.claimedAt,
3174
+ required this.requestedDevice,
3175
+ required this.requestLocation,
3176
+ });
3177
+
3178
+ factory QrLoginApprovalPreview.fromJson(Map<dynamic, dynamic> json) {
3179
+ return QrLoginApprovalPreview(
3180
+ challengeId: json['challengeId']?.toString() ?? '',
3181
+ status: json['status']?.toString().ifEmpty('pending') ?? 'pending',
3182
+ requestedAt: _parseOptionalTimestamp(json['requestedAt']?.toString()),
3183
+ expiresAt: _parseOptionalTimestamp(json['expiresAt']?.toString()),
3184
+ approvedAt: _parseOptionalTimestamp(json['approvedAt']?.toString()),
3185
+ claimedAt: _parseOptionalTimestamp(json['claimedAt']?.toString()),
3186
+ requestedDevice: QrLoginRequestedDevice.fromJson(
3187
+ json['requestedDevice'] is Map
3188
+ ? json['requestedDevice'] as Map
3189
+ : const <String, dynamic>{},
3190
+ ),
3191
+ requestLocation: QrLoginRequestLocation.fromJson(
3192
+ json['requestLocation'] is Map
3193
+ ? json['requestLocation'] as Map
3194
+ : const <String, dynamic>{},
3195
+ ),
3196
+ );
3197
+ }
3198
+
3199
+ final String challengeId;
3200
+ final String status;
3201
+ final DateTime? requestedAt;
3202
+ final DateTime? expiresAt;
3203
+ final DateTime? approvedAt;
3204
+ final DateTime? claimedAt;
3205
+ final QrLoginRequestedDevice requestedDevice;
3206
+ final QrLoginRequestLocation requestLocation;
3207
+
3208
+ bool get canApprove => status == 'pending' || status == 'approved';
3209
+ bool get isClaimed => status == 'claimed';
3210
+ bool get isExpired =>
3211
+ status == 'expired' ||
3212
+ (expiresAt != null && expiresAt!.isBefore(DateTime.now()));
3213
+ }
3214
+
3215
+ class QrLoginRequestedDevice {
3216
+ const QrLoginRequestedDevice({
3217
+ required this.label,
3218
+ required this.platformLabel,
3219
+ required this.browserLabel,
3220
+ required this.deviceClass,
3221
+ required this.userAgent,
3222
+ required this.metadata,
3223
+ });
3224
+
3225
+ factory QrLoginRequestedDevice.fromJson(Map<dynamic, dynamic> json) {
3226
+ return QrLoginRequestedDevice(
3227
+ label:
3228
+ json['label']?.toString().ifEmpty('Unknown device') ??
3229
+ 'Unknown device',
3230
+ platformLabel:
3231
+ json['platformLabel']?.toString().ifEmpty('Unknown') ?? 'Unknown',
3232
+ browserLabel:
3233
+ json['browserLabel']?.toString().ifEmpty('Unknown') ?? 'Unknown',
3234
+ deviceClass:
3235
+ json['deviceClass']?.toString().ifEmpty('unknown') ?? 'unknown',
3236
+ userAgent: json['userAgent']?.toString() ?? '',
3237
+ metadata: json['metadata'] is Map
3238
+ ? Map<String, dynamic>.from(json['metadata'] as Map)
3239
+ : const <String, dynamic>{},
3240
+ );
3241
+ }
3242
+
3243
+ final String label;
3244
+ final String platformLabel;
3245
+ final String browserLabel;
3246
+ final String deviceClass;
3247
+ final String userAgent;
3248
+ final Map<String, dynamic> metadata;
3249
+ }
3250
+
3251
+ class QrLoginRequestLocation {
3252
+ const QrLoginRequestLocation({
3253
+ required this.label,
3254
+ required this.ipAddress,
3255
+ required this.city,
3256
+ required this.region,
3257
+ required this.country,
3258
+ required this.timezone,
3259
+ });
3260
+
3261
+ factory QrLoginRequestLocation.fromJson(Map<dynamic, dynamic> json) {
3262
+ return QrLoginRequestLocation(
3263
+ label: json['label']?.toString().ifEmpty('Unknown') ?? 'Unknown',
3264
+ ipAddress: json['ipAddress']?.toString(),
3265
+ city: json['city']?.toString(),
3266
+ region: json['region']?.toString(),
3267
+ country: json['country']?.toString(),
3268
+ timezone: json['timezone']?.toString(),
3269
+ );
3270
+ }
3271
+
3272
+ final String label;
3273
+ final String? ipAddress;
3274
+ final String? city;
3275
+ final String? region;
3276
+ final String? country;
3277
+ final String? timezone;
3278
+ }
3279
+
3280
+ class QrLoginScanPayload {
3281
+ const QrLoginScanPayload({
3282
+ required this.backendUrl,
3283
+ required this.challengeId,
3284
+ required this.secret,
3285
+ required this.version,
3286
+ });
3287
+
3288
+ static QrLoginScanPayload? tryParse(String raw) {
3289
+ final trimmed = raw.trim();
3290
+ if (trimmed.isEmpty) return null;
3291
+
3292
+ try {
3293
+ final uri = Uri.parse(trimmed);
3294
+ if (uri.scheme == 'neoagent' && uri.host == 'qr-login') {
3295
+ final backendUrl = uri.queryParameters['backend']?.trim() ?? '';
3296
+ final challengeId = uri.queryParameters['challenge']?.trim() ?? '';
3297
+ final secret = uri.queryParameters['secret']?.trim() ?? '';
3298
+ final version = uri.queryParameters['v']?.trim() ?? '1';
3299
+ if (backendUrl.isNotEmpty &&
3300
+ challengeId.isNotEmpty &&
3301
+ secret.isNotEmpty) {
3302
+ return QrLoginScanPayload(
3303
+ backendUrl: backendUrl,
3304
+ challengeId: challengeId,
3305
+ secret: secret,
3306
+ version: version,
3307
+ );
3308
+ }
3309
+ }
3310
+ } catch (_) {}
3311
+
3312
+ try {
3313
+ final decoded = jsonDecode(trimmed);
3314
+ if (decoded is! Map) return null;
3315
+ final backendUrl = decoded['backendUrl']?.toString().trim() ?? '';
3316
+ final challengeId = decoded['challengeId']?.toString().trim() ?? '';
3317
+ final secret = decoded['secret']?.toString().trim() ?? '';
3318
+ final version = decoded['version']?.toString().trim() ?? '1';
3319
+ if (backendUrl.isEmpty || challengeId.isEmpty || secret.isEmpty) {
3320
+ return null;
3321
+ }
3322
+ return QrLoginScanPayload(
3323
+ backendUrl: backendUrl,
3324
+ challengeId: challengeId,
3325
+ secret: secret,
3326
+ version: version,
3327
+ );
3328
+ } catch (_) {
3329
+ return null;
3330
+ }
3331
+ }
3332
+
3333
+ final String backendUrl;
3334
+ final String challengeId;
3335
+ final String secret;
3336
+ final String version;
3337
+ }
3338
+
3339
+ class ActiveRunState {
3340
+ const ActiveRunState({
3341
+ required this.runId,
3342
+ required this.title,
3343
+ required this.model,
3344
+ required this.triggerSource,
3345
+ required this.phase,
3346
+ required this.iteration,
3347
+ this.pendingSteeringCount = 0,
3348
+ });
3349
+
3350
+ factory ActiveRunState.pending(String task) {
3351
+ return ActiveRunState(
3352
+ runId: 'pending',
3353
+ title: task,
3354
+ model: '',
3355
+ triggerSource: 'web',
3356
+ phase: 'Queued',
3357
+ iteration: 0,
3358
+ pendingSteeringCount: 0,
3359
+ );
3360
+ }
3361
+
3362
+ final String runId;
3363
+ final String title;
3364
+ final String model;
3365
+ final String triggerSource;
3366
+ final String phase;
3367
+ final int iteration;
3368
+ final int pendingSteeringCount;
3369
+
3370
+ ActiveRunState copyWith({
3371
+ String? runId,
3372
+ String? title,
3373
+ String? model,
3374
+ String? triggerSource,
3375
+ String? phase,
3376
+ int? iteration,
3377
+ int? pendingSteeringCount,
3378
+ }) {
3379
+ return ActiveRunState(
3380
+ runId: runId ?? this.runId,
3381
+ title: title ?? this.title,
3382
+ model: model ?? this.model,
3383
+ triggerSource: triggerSource ?? this.triggerSource,
3384
+ phase: phase ?? this.phase,
3385
+ iteration: iteration ?? this.iteration,
3386
+ pendingSteeringCount: pendingSteeringCount ?? this.pendingSteeringCount,
3387
+ );
3388
+ }
3389
+ }
3390
+
3391
+ class ToolEventItem {
3392
+ const ToolEventItem({
3393
+ required this.id,
3394
+ required this.toolName,
3395
+ required this.type,
3396
+ required this.status,
3397
+ required this.summary,
3398
+ });
3399
+
3400
+ final String id;
3401
+ final String toolName;
3402
+ final String type;
3403
+ final String status;
3404
+ final String summary;
3405
+
3406
+ String get statusLabel => _titleCase(status.replaceAll('_', ' '));
3407
+
3408
+ bool get isPlanningRelated =>
3409
+ type == 'analysis' || type == 'planning' || toolName == 'plan';
3410
+
3411
+ bool get isHelperRelated {
3412
+ final label = '${type.toLowerCase()} ${toolName.toLowerCase()}';
3413
+ return label.contains('subagent') || label.contains('helper');
3414
+ }
3415
+
3416
+ bool get isBrowserRelated {
3417
+ final label = '${type.toLowerCase()} ${toolName.toLowerCase()}';
3418
+ return label.contains('browser') ||
3419
+ label.contains('page') ||
3420
+ label.contains('screenshot');
3421
+ }
3422
+
3423
+ bool get isMessagingRelated {
3424
+ final label = '${type.toLowerCase()} ${toolName.toLowerCase()}';
3425
+ return label.contains('message') ||
3426
+ label.contains('telegram') ||
3427
+ label.contains('discord') ||
3428
+ label.contains('whatsapp') ||
3429
+ label.contains('slack');
3430
+ }
3431
+
3432
+ bool get isWebRelated => isBrowserRelated || isMessagingRelated;
3433
+
3434
+ String get laneLabel {
3435
+ if (isPlanningRelated) {
3436
+ return 'Planning';
3437
+ }
3438
+ if (isHelperRelated) {
3439
+ return 'Helper';
3440
+ }
3441
+ if (isWebRelated) {
3442
+ return 'Web';
3443
+ }
3444
+ if (type == 'verification') {
3445
+ return 'Verification';
3446
+ }
3447
+ return 'Execution';
3448
+ }
3449
+
3450
+ IconData get laneIcon {
3451
+ if (isPlanningRelated) {
3452
+ return Icons.route_outlined;
3453
+ }
3454
+ if (isHelperRelated) {
3455
+ return Icons.account_tree_outlined;
3456
+ }
3457
+ if (isBrowserRelated) {
3458
+ return Icons.language_outlined;
3459
+ }
3460
+ if (isMessagingRelated) {
3461
+ return Icons.chat_bubble_outline;
3462
+ }
3463
+ if (type == 'verification') {
3464
+ return Icons.verified_outlined;
3465
+ }
3466
+ if (status == 'failed') {
3467
+ return Icons.error_outline;
3468
+ }
3469
+ return Icons.build_outlined;
3470
+ }
3471
+
3472
+ String get compactSummary => _condenseRunText(summary, maxLength: 120);
3473
+ }