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