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,2024 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
class SettingsPanel extends StatefulWidget {
|
|
4
|
+
const SettingsPanel({super.key, required this.controller});
|
|
5
|
+
|
|
6
|
+
final NeoAgentController controller;
|
|
7
|
+
|
|
8
|
+
@override
|
|
9
|
+
State<SettingsPanel> createState() => _SettingsPanelState();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const Map<String, List<String>> _voiceLiveModelsByProvider =
|
|
13
|
+
<String, List<String>>{
|
|
14
|
+
'openai': <String>[
|
|
15
|
+
'gpt-4o-realtime-preview',
|
|
16
|
+
'gpt-4o-mini-realtime-preview',
|
|
17
|
+
],
|
|
18
|
+
'gemini': <String>['gemini-3.1-flash-live-preview'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const Map<String, List<String>> _voiceLiveVoicesByProvider =
|
|
22
|
+
<String, List<String>>{
|
|
23
|
+
'openai': <String>[
|
|
24
|
+
'alloy',
|
|
25
|
+
'ash',
|
|
26
|
+
'ballad',
|
|
27
|
+
'coral',
|
|
28
|
+
'echo',
|
|
29
|
+
'fable',
|
|
30
|
+
'nova',
|
|
31
|
+
'onyx',
|
|
32
|
+
'sage',
|
|
33
|
+
'shimmer',
|
|
34
|
+
'verse',
|
|
35
|
+
'marin',
|
|
36
|
+
'cedar',
|
|
37
|
+
],
|
|
38
|
+
'gemini': <String>[
|
|
39
|
+
'Kore',
|
|
40
|
+
'Puck',
|
|
41
|
+
'Charon',
|
|
42
|
+
'Zephyr',
|
|
43
|
+
'Leda',
|
|
44
|
+
'Aoede',
|
|
45
|
+
'Fenrir',
|
|
46
|
+
'Orus',
|
|
47
|
+
'Achernar',
|
|
48
|
+
'Achird',
|
|
49
|
+
'Algenib',
|
|
50
|
+
'Algieba',
|
|
51
|
+
'Alnilam',
|
|
52
|
+
'Autonoe',
|
|
53
|
+
'Callirrhoe',
|
|
54
|
+
'Despina',
|
|
55
|
+
'Enceladus',
|
|
56
|
+
'Erinome',
|
|
57
|
+
'Gacrux',
|
|
58
|
+
'Iocaste',
|
|
59
|
+
'Isonoe',
|
|
60
|
+
'Laomedeia',
|
|
61
|
+
'Larissa',
|
|
62
|
+
'Lysithea',
|
|
63
|
+
'Megaclite',
|
|
64
|
+
'Mimosa',
|
|
65
|
+
'Pulcherrima',
|
|
66
|
+
'Rasalgethi',
|
|
67
|
+
'Sadachbia',
|
|
68
|
+
'Sulafat',
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
class _SettingsPanelState extends State<SettingsPanel> {
|
|
73
|
+
late bool _headlessBrowser;
|
|
74
|
+
late String _browserBackend;
|
|
75
|
+
late bool _smarterSelector;
|
|
76
|
+
late Set<String> _enabledModels;
|
|
77
|
+
late String _defaultChatModel;
|
|
78
|
+
late String _defaultSubagentModel;
|
|
79
|
+
late String _defaultRecordingTranscriptionModel;
|
|
80
|
+
late String _defaultRecordingSummaryModel;
|
|
81
|
+
late String _fallbackModel;
|
|
82
|
+
late String _defaultSpeechModel;
|
|
83
|
+
late String _voiceLiveProvider;
|
|
84
|
+
late String _voiceLiveModel;
|
|
85
|
+
late String _voiceLiveVoice;
|
|
86
|
+
final Map<String, bool> _providerEnabled = <String, bool>{};
|
|
87
|
+
final Map<String, TextEditingController> _providerBaseUrlControllers =
|
|
88
|
+
<String, TextEditingController>{};
|
|
89
|
+
final Set<String> _expandedProviderIds = <String>{};
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
void initState() {
|
|
93
|
+
super.initState();
|
|
94
|
+
_hydrate();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
void dispose() {
|
|
99
|
+
for (final controller in _providerBaseUrlControllers.values) {
|
|
100
|
+
controller.dispose();
|
|
101
|
+
}
|
|
102
|
+
super.dispose();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
void didUpdateWidget(covariant SettingsPanel oldWidget) {
|
|
107
|
+
super.didUpdateWidget(oldWidget);
|
|
108
|
+
if (oldWidget.controller.settings != widget.controller.settings ||
|
|
109
|
+
oldWidget.controller.aiProviders != widget.controller.aiProviders ||
|
|
110
|
+
oldWidget.controller.supportedModels !=
|
|
111
|
+
widget.controller.supportedModels) {
|
|
112
|
+
_hydrate();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
void _hydrate() {
|
|
117
|
+
final controller = widget.controller;
|
|
118
|
+
final knownModels = controller.supportedModels
|
|
119
|
+
.map((model) => model.id)
|
|
120
|
+
.toSet();
|
|
121
|
+
final availableModels = controller.supportedModels
|
|
122
|
+
.where((model) => model.available)
|
|
123
|
+
.map((model) => model.id)
|
|
124
|
+
.toSet();
|
|
125
|
+
_headlessBrowser = controller.headlessBrowser;
|
|
126
|
+
_browserBackend = _normalizeBrowserBackend(controller.browserBackend);
|
|
127
|
+
_smarterSelector = controller.smarterSelector;
|
|
128
|
+
_enabledModels = controller.enabledModelIds
|
|
129
|
+
.where((id) => knownModels.contains(id))
|
|
130
|
+
.toSet();
|
|
131
|
+
if (_enabledModels.isEmpty && availableModels.isNotEmpty) {
|
|
132
|
+
_enabledModels = availableModels;
|
|
133
|
+
}
|
|
134
|
+
_defaultChatModel = controller.defaultChatModel;
|
|
135
|
+
_defaultSubagentModel = controller.defaultSubagentModel;
|
|
136
|
+
_defaultRecordingTranscriptionModel =
|
|
137
|
+
controller.defaultRecordingTranscriptionModel;
|
|
138
|
+
_defaultRecordingSummaryModel = controller.defaultRecordingSummaryModel;
|
|
139
|
+
_fallbackModel = controller.fallbackModel;
|
|
140
|
+
_defaultSpeechModel = controller.defaultSpeechModel;
|
|
141
|
+
_voiceLiveProvider = controller.voiceLiveProvider;
|
|
142
|
+
_voiceLiveModel = controller.voiceLiveModel;
|
|
143
|
+
_voiceLiveVoice = controller.voiceLiveVoice;
|
|
144
|
+
if (!_voiceLiveModelsByProvider.containsKey(_voiceLiveProvider)) {
|
|
145
|
+
_voiceLiveProvider = 'openai';
|
|
146
|
+
}
|
|
147
|
+
if (!(_voiceLiveModelsByProvider[_voiceLiveProvider]?.contains(
|
|
148
|
+
_voiceLiveModel,
|
|
149
|
+
) ??
|
|
150
|
+
false)) {
|
|
151
|
+
_voiceLiveModel = _voiceLiveModelsByProvider[_voiceLiveProvider]!.first;
|
|
152
|
+
}
|
|
153
|
+
final liveVoiceOptions =
|
|
154
|
+
_voiceLiveVoicesByProvider[_voiceLiveProvider] ?? const <String>[];
|
|
155
|
+
if (liveVoiceOptions.isNotEmpty &&
|
|
156
|
+
!liveVoiceOptions.contains(_voiceLiveVoice)) {
|
|
157
|
+
_voiceLiveVoice = liveVoiceOptions.first;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
final providerConfigs = controller.aiProviderConfigs;
|
|
161
|
+
final providerIds = <String>{
|
|
162
|
+
...providerConfigs.keys,
|
|
163
|
+
...controller.aiProviders.map((provider) => provider.id),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
for (final providerId in providerIds) {
|
|
167
|
+
final config =
|
|
168
|
+
providerConfigs[providerId] ?? AiProviderConfig.empty(providerId);
|
|
169
|
+
_providerEnabled[providerId] = config.enabled;
|
|
170
|
+
_syncTextController(
|
|
171
|
+
_providerBaseUrlControllers,
|
|
172
|
+
providerId,
|
|
173
|
+
config.baseUrl,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_pruneControllers(_providerBaseUrlControllers, providerIds);
|
|
178
|
+
_providerEnabled.removeWhere((id, _) => !providerIds.contains(id));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
String _normalizeBrowserBackend(String value) {
|
|
182
|
+
final normalized = value.trim().toLowerCase();
|
|
183
|
+
return normalized == 'extension' ? 'extension' : 'cloud';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@override
|
|
187
|
+
Widget build(BuildContext context) {
|
|
188
|
+
final controller = widget.controller;
|
|
189
|
+
final availableModels = controller.supportedModels
|
|
190
|
+
.where((model) => model.available)
|
|
191
|
+
.toList();
|
|
192
|
+
final routingModels = availableModels.isEmpty
|
|
193
|
+
? controller.supportedModels
|
|
194
|
+
: availableModels;
|
|
195
|
+
final modelChoices = <DropdownMenuItem<String>>[
|
|
196
|
+
const DropdownMenuItem<String>(
|
|
197
|
+
value: 'auto',
|
|
198
|
+
child: Text('Smart Selector (Auto)'),
|
|
199
|
+
),
|
|
200
|
+
...routingModels.map(
|
|
201
|
+
(model) =>
|
|
202
|
+
DropdownMenuItem<String>(value: model.id, child: Text(model.label)),
|
|
203
|
+
),
|
|
204
|
+
];
|
|
205
|
+
final enabledSmartModels = _enabledModels
|
|
206
|
+
.where((id) => routingModels.any((model) => model.id == id))
|
|
207
|
+
.length;
|
|
208
|
+
|
|
209
|
+
return ListView(
|
|
210
|
+
padding: _pagePadding(context),
|
|
211
|
+
children: <Widget>[
|
|
212
|
+
_PageTitle(
|
|
213
|
+
title: 'Settings',
|
|
214
|
+
subtitle:
|
|
215
|
+
'Workspace, models, recording, update, and diagnostics controls.',
|
|
216
|
+
trailing: FilledButton.icon(
|
|
217
|
+
onPressed: controller.isSavingSettings
|
|
218
|
+
? null
|
|
219
|
+
: () => controller.saveSettings(
|
|
220
|
+
headlessBrowser: _headlessBrowser,
|
|
221
|
+
browserBackend: _browserBackend == 'extension'
|
|
222
|
+
? 'extension'
|
|
223
|
+
: controller.cloudBrowserBackend,
|
|
224
|
+
smarterSelector: _smarterSelector,
|
|
225
|
+
enabledModels: _enabledModels.toList(),
|
|
226
|
+
defaultChatModel: _defaultChatModel,
|
|
227
|
+
defaultSubagentModel: _defaultSubagentModel,
|
|
228
|
+
defaultRecordingTranscriptionProvider: 'deepgram',
|
|
229
|
+
defaultRecordingTranscriptionModel:
|
|
230
|
+
_defaultRecordingTranscriptionModel,
|
|
231
|
+
defaultRecordingSummaryProvider: _providerForSelectedModel(
|
|
232
|
+
_defaultRecordingSummaryModel,
|
|
233
|
+
controller.supportedModels,
|
|
234
|
+
),
|
|
235
|
+
defaultRecordingSummaryModel: _defaultRecordingSummaryModel,
|
|
236
|
+
fallbackModel: _fallbackModel,
|
|
237
|
+
defaultSpeechModel: _defaultSpeechModel,
|
|
238
|
+
voiceSttProvider: controller.voiceSttProvider,
|
|
239
|
+
voiceSttModel: controller.voiceSttModel,
|
|
240
|
+
voiceTtsProvider: controller.voiceTtsProvider,
|
|
241
|
+
voiceTtsModel: controller.voiceTtsModel,
|
|
242
|
+
voiceTtsVoice: controller.voiceTtsVoice,
|
|
243
|
+
voiceRuntimeMode: 'live',
|
|
244
|
+
voiceLiveProvider: _voiceLiveProvider,
|
|
245
|
+
voiceLiveModel: _voiceLiveModel,
|
|
246
|
+
voiceLiveVoice: _voiceLiveVoice,
|
|
247
|
+
aiProviderConfigs: _buildProviderPayload(),
|
|
248
|
+
),
|
|
249
|
+
style: FilledButton.styleFrom(backgroundColor: _accent),
|
|
250
|
+
icon: controller.isSavingSettings
|
|
251
|
+
? const SizedBox.square(
|
|
252
|
+
dimension: 16,
|
|
253
|
+
child: CircularProgressIndicator(
|
|
254
|
+
strokeWidth: 2,
|
|
255
|
+
color: Colors.white,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
: Icon(Icons.save_outlined),
|
|
259
|
+
label: Text('Save'),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
if (controller.errorMessage != null) ...<Widget>[
|
|
263
|
+
_InlineError(message: controller.errorMessage!),
|
|
264
|
+
const SizedBox(height: 16),
|
|
265
|
+
],
|
|
266
|
+
_buildSettingsOverview(controller, availableModels.length),
|
|
267
|
+
const SizedBox(height: 16),
|
|
268
|
+
_buildWorkspaceSection(controller),
|
|
269
|
+
const SizedBox(height: 16),
|
|
270
|
+
_buildModelsSection(
|
|
271
|
+
controller: controller,
|
|
272
|
+
modelChoices: modelChoices,
|
|
273
|
+
routingModels: routingModels,
|
|
274
|
+
availableModels: availableModels,
|
|
275
|
+
enabledSmartModels: enabledSmartModels,
|
|
276
|
+
),
|
|
277
|
+
const SizedBox(height: 16),
|
|
278
|
+
_buildVoiceAndRecordingSection(
|
|
279
|
+
controller: controller,
|
|
280
|
+
modelChoices: modelChoices,
|
|
281
|
+
routingModels: routingModels,
|
|
282
|
+
),
|
|
283
|
+
const SizedBox(height: 16),
|
|
284
|
+
if (_supportsDesktopShell) ...<Widget>[
|
|
285
|
+
_buildDesktopSection(controller),
|
|
286
|
+
const SizedBox(height: 16),
|
|
287
|
+
],
|
|
288
|
+
_buildUpdatesSection(controller),
|
|
289
|
+
const SizedBox(height: 16),
|
|
290
|
+
_buildDiagnosticsSection(controller),
|
|
291
|
+
],
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
Widget _buildSettingsOverview(
|
|
296
|
+
NeoAgentController controller,
|
|
297
|
+
int availableModelCount,
|
|
298
|
+
) {
|
|
299
|
+
final platformLabel = kIsWeb ? 'Web' : defaultTargetPlatform.name;
|
|
300
|
+
return Card(
|
|
301
|
+
child: Padding(
|
|
302
|
+
padding: const EdgeInsets.all(20),
|
|
303
|
+
child: Column(
|
|
304
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
305
|
+
children: <Widget>[
|
|
306
|
+
const _SectionTitle('Overview'),
|
|
307
|
+
const SizedBox(height: 10),
|
|
308
|
+
Text(
|
|
309
|
+
'Configure workspace behavior, then models, recording defaults, and updates.',
|
|
310
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
311
|
+
),
|
|
312
|
+
const SizedBox(height: 14),
|
|
313
|
+
Wrap(
|
|
314
|
+
spacing: 10,
|
|
315
|
+
runSpacing: 10,
|
|
316
|
+
children: <Widget>[
|
|
317
|
+
_MetaPill(
|
|
318
|
+
icon: Icons.devices_outlined,
|
|
319
|
+
label:
|
|
320
|
+
'Platform ${platformLabel[0].toUpperCase()}${platformLabel.substring(1)}',
|
|
321
|
+
),
|
|
322
|
+
_MetaPill(
|
|
323
|
+
icon: Icons.memory_outlined,
|
|
324
|
+
label: '$availableModelCount models ready',
|
|
325
|
+
),
|
|
326
|
+
_MetaPill(
|
|
327
|
+
icon: Icons.hub_outlined,
|
|
328
|
+
label: '${controller.aiProviders.length} providers',
|
|
329
|
+
),
|
|
330
|
+
_MetaPill(
|
|
331
|
+
icon: Icons.auto_awesome_outlined,
|
|
332
|
+
label: _smarterSelector
|
|
333
|
+
? 'Smart selector on'
|
|
334
|
+
: 'Manual routing',
|
|
335
|
+
),
|
|
336
|
+
if (_supportsDesktopShell)
|
|
337
|
+
_MetaPill(
|
|
338
|
+
icon: Icons.desktop_windows_outlined,
|
|
339
|
+
label: controller.desktopCompanionEnabled
|
|
340
|
+
? 'Desktop companion enabled'
|
|
341
|
+
: 'Desktop-only controls available',
|
|
342
|
+
),
|
|
343
|
+
],
|
|
344
|
+
),
|
|
345
|
+
],
|
|
346
|
+
),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
Widget _buildWorkspaceSection(NeoAgentController controller) {
|
|
352
|
+
return Card(
|
|
353
|
+
child: Padding(
|
|
354
|
+
padding: const EdgeInsets.all(20),
|
|
355
|
+
child: Column(
|
|
356
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
357
|
+
children: <Widget>[
|
|
358
|
+
const _SectionTitle('Workspace'),
|
|
359
|
+
const SizedBox(height: 10),
|
|
360
|
+
Text(
|
|
361
|
+
'Controls for how the app runs on this device and in the browser.',
|
|
362
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
363
|
+
),
|
|
364
|
+
const SizedBox(height: 16),
|
|
365
|
+
Text(
|
|
366
|
+
'Browser Runtime',
|
|
367
|
+
style: TextStyle(
|
|
368
|
+
fontWeight: FontWeight.w700,
|
|
369
|
+
color: _textPrimary,
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
const SizedBox(height: 12),
|
|
373
|
+
_SettingToggle(
|
|
374
|
+
title: 'Run browser headless',
|
|
375
|
+
subtitle:
|
|
376
|
+
'Keep browser automation off-screen when visible windows are not needed.',
|
|
377
|
+
value: _headlessBrowser,
|
|
378
|
+
onChanged: (value) => setState(() => _headlessBrowser = value),
|
|
379
|
+
),
|
|
380
|
+
const SizedBox(height: 12),
|
|
381
|
+
DropdownButtonFormField<String>(
|
|
382
|
+
initialValue: _browserBackend,
|
|
383
|
+
decoration: const InputDecoration(
|
|
384
|
+
labelText: 'Browser backend',
|
|
385
|
+
helperText:
|
|
386
|
+
'Cloud uses this deployment. Extension uses a paired Chrome browser.',
|
|
387
|
+
),
|
|
388
|
+
items: const <DropdownMenuItem<String>>[
|
|
389
|
+
DropdownMenuItem<String>(
|
|
390
|
+
value: 'cloud',
|
|
391
|
+
child: Text('Cloud (local)'),
|
|
392
|
+
),
|
|
393
|
+
DropdownMenuItem<String>(
|
|
394
|
+
value: 'extension',
|
|
395
|
+
child: Text('Chrome extension'),
|
|
396
|
+
),
|
|
397
|
+
],
|
|
398
|
+
onChanged: (value) {
|
|
399
|
+
if (value != null) {
|
|
400
|
+
setState(() => _browserBackend = value);
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
),
|
|
404
|
+
const SizedBox(height: 10),
|
|
405
|
+
Text(
|
|
406
|
+
_browserBackend == 'extension'
|
|
407
|
+
? (controller.browserExtensionConnected
|
|
408
|
+
? 'Chrome extension connected.'
|
|
409
|
+
: 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
|
|
410
|
+
: controller.cloudBrowserBackend == 'vm'
|
|
411
|
+
? "Cloud uses this deployment's isolated VM browser runtime."
|
|
412
|
+
: "Cloud uses this deployment's local host browser runtime.",
|
|
413
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
414
|
+
),
|
|
415
|
+
const SizedBox(height: 10),
|
|
416
|
+
Wrap(
|
|
417
|
+
spacing: 10,
|
|
418
|
+
runSpacing: 10,
|
|
419
|
+
children: <Widget>[
|
|
420
|
+
OutlinedButton.icon(
|
|
421
|
+
onPressed: controller.downloadBrowserExtension,
|
|
422
|
+
icon: Icon(Icons.download_outlined),
|
|
423
|
+
label: Text('Download extension'),
|
|
424
|
+
),
|
|
425
|
+
OutlinedButton.icon(
|
|
426
|
+
onPressed: controller.refreshBrowserExtensionStatus,
|
|
427
|
+
icon: Icon(Icons.sync),
|
|
428
|
+
label: Text('Refresh status'),
|
|
429
|
+
),
|
|
430
|
+
],
|
|
431
|
+
),
|
|
432
|
+
const Divider(height: 32),
|
|
433
|
+
Text(
|
|
434
|
+
'Routing Behavior',
|
|
435
|
+
style: TextStyle(
|
|
436
|
+
fontWeight: FontWeight.w700,
|
|
437
|
+
color: _textPrimary,
|
|
438
|
+
),
|
|
439
|
+
),
|
|
440
|
+
const SizedBox(height: 12),
|
|
441
|
+
_SettingToggle(
|
|
442
|
+
title: 'Smart model selection',
|
|
443
|
+
subtitle:
|
|
444
|
+
'Automatically choose the best enabled model for each task type.',
|
|
445
|
+
value: _smarterSelector,
|
|
446
|
+
onChanged: (value) => setState(() => _smarterSelector = value),
|
|
447
|
+
),
|
|
448
|
+
],
|
|
449
|
+
),
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
Widget _buildModelsSection({
|
|
455
|
+
required NeoAgentController controller,
|
|
456
|
+
required List<DropdownMenuItem<String>> modelChoices,
|
|
457
|
+
required List<ModelMeta> routingModels,
|
|
458
|
+
required List<ModelMeta> availableModels,
|
|
459
|
+
required int enabledSmartModels,
|
|
460
|
+
}) {
|
|
461
|
+
return Card(
|
|
462
|
+
child: Padding(
|
|
463
|
+
padding: const EdgeInsets.all(20),
|
|
464
|
+
child: Column(
|
|
465
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
466
|
+
children: <Widget>[
|
|
467
|
+
const _SectionTitle('Models'),
|
|
468
|
+
const SizedBox(height: 10),
|
|
469
|
+
Text(
|
|
470
|
+
'Enable providers, then choose defaults for chat, agents, fallback behavior, and smart routing.',
|
|
471
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
472
|
+
),
|
|
473
|
+
const SizedBox(height: 16),
|
|
474
|
+
Text(
|
|
475
|
+
'Providers',
|
|
476
|
+
style: TextStyle(
|
|
477
|
+
fontWeight: FontWeight.w700,
|
|
478
|
+
color: _textPrimary,
|
|
479
|
+
),
|
|
480
|
+
),
|
|
481
|
+
const SizedBox(height: 14),
|
|
482
|
+
if (controller.aiProviders.isEmpty)
|
|
483
|
+
Text(
|
|
484
|
+
'Provider metadata is unavailable on this server version.',
|
|
485
|
+
style: TextStyle(color: _textSecondary),
|
|
486
|
+
)
|
|
487
|
+
else
|
|
488
|
+
LayoutBuilder(
|
|
489
|
+
builder: (context, constraints) {
|
|
490
|
+
final compact = constraints.maxWidth < 960;
|
|
491
|
+
final cardWidth = compact
|
|
492
|
+
? constraints.maxWidth
|
|
493
|
+
: (constraints.maxWidth - 16) / 2;
|
|
494
|
+
return Wrap(
|
|
495
|
+
spacing: 16,
|
|
496
|
+
runSpacing: 16,
|
|
497
|
+
children: controller.aiProviders
|
|
498
|
+
.where(
|
|
499
|
+
(provider) =>
|
|
500
|
+
provider.available ||
|
|
501
|
+
_providerEnabled[provider.id] == true ||
|
|
502
|
+
controller
|
|
503
|
+
.aiProviderConfigs[provider.id]
|
|
504
|
+
?.enabled ==
|
|
505
|
+
true,
|
|
506
|
+
)
|
|
507
|
+
.map((provider) {
|
|
508
|
+
return SizedBox(
|
|
509
|
+
width: cardWidth,
|
|
510
|
+
child: _AiProviderCard(
|
|
511
|
+
provider: provider,
|
|
512
|
+
enabled:
|
|
513
|
+
_providerEnabled[provider.id] ??
|
|
514
|
+
controller
|
|
515
|
+
.aiProviderConfigs[provider.id]
|
|
516
|
+
?.enabled ??
|
|
517
|
+
true,
|
|
518
|
+
models: controller.supportedModels
|
|
519
|
+
.where(
|
|
520
|
+
(model) => model.provider == provider.id,
|
|
521
|
+
)
|
|
522
|
+
.toList(),
|
|
523
|
+
baseUrlController:
|
|
524
|
+
_providerBaseUrlControllers[provider.id]!,
|
|
525
|
+
expanded: _expandedProviderIds.contains(
|
|
526
|
+
provider.id,
|
|
527
|
+
),
|
|
528
|
+
onEnabledChanged: (value) {
|
|
529
|
+
setState(() {
|
|
530
|
+
_providerEnabled[provider.id] = value;
|
|
531
|
+
});
|
|
532
|
+
},
|
|
533
|
+
onExpandToggle: () {
|
|
534
|
+
setState(() {
|
|
535
|
+
if (_expandedProviderIds.contains(
|
|
536
|
+
provider.id,
|
|
537
|
+
)) {
|
|
538
|
+
_expandedProviderIds.remove(provider.id);
|
|
539
|
+
} else {
|
|
540
|
+
_expandedProviderIds.add(provider.id);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
})
|
|
547
|
+
.toList(),
|
|
548
|
+
);
|
|
549
|
+
},
|
|
550
|
+
),
|
|
551
|
+
const Divider(height: 32),
|
|
552
|
+
Text(
|
|
553
|
+
'Default Routing',
|
|
554
|
+
style: TextStyle(
|
|
555
|
+
fontWeight: FontWeight.w700,
|
|
556
|
+
color: _textPrimary,
|
|
557
|
+
),
|
|
558
|
+
),
|
|
559
|
+
const SizedBox(height: 12),
|
|
560
|
+
if (routingModels.isNotEmpty)
|
|
561
|
+
LayoutBuilder(
|
|
562
|
+
builder: (context, constraints) {
|
|
563
|
+
final compact = constraints.maxWidth < 940;
|
|
564
|
+
final cardWidth = compact
|
|
565
|
+
? constraints.maxWidth
|
|
566
|
+
: (constraints.maxWidth - 24) / 3;
|
|
567
|
+
return Wrap(
|
|
568
|
+
spacing: 12,
|
|
569
|
+
runSpacing: 12,
|
|
570
|
+
children: <Widget>[
|
|
571
|
+
SizedBox(
|
|
572
|
+
width: cardWidth,
|
|
573
|
+
child: _RoutingSelectCard(
|
|
574
|
+
label: 'Chat',
|
|
575
|
+
icon: Icons.chat_bubble_outline,
|
|
576
|
+
value: _ensureModelValue(
|
|
577
|
+
_defaultChatModel,
|
|
578
|
+
routingModels,
|
|
579
|
+
allowAuto: true,
|
|
580
|
+
),
|
|
581
|
+
items: modelChoices,
|
|
582
|
+
onChanged: (value) {
|
|
583
|
+
if (value != null) {
|
|
584
|
+
setState(() => _defaultChatModel = value);
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
),
|
|
588
|
+
),
|
|
589
|
+
SizedBox(
|
|
590
|
+
width: cardWidth,
|
|
591
|
+
child: _RoutingSelectCard(
|
|
592
|
+
label: 'Sub-agent',
|
|
593
|
+
icon: Icons.bolt_outlined,
|
|
594
|
+
value: _ensureModelValue(
|
|
595
|
+
_defaultSubagentModel,
|
|
596
|
+
routingModels,
|
|
597
|
+
allowAuto: true,
|
|
598
|
+
),
|
|
599
|
+
items: modelChoices,
|
|
600
|
+
onChanged: (value) {
|
|
601
|
+
if (value != null) {
|
|
602
|
+
setState(() => _defaultSubagentModel = value);
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
),
|
|
606
|
+
),
|
|
607
|
+
SizedBox(
|
|
608
|
+
width: cardWidth,
|
|
609
|
+
child: _RoutingSelectCard(
|
|
610
|
+
label: 'Fallback',
|
|
611
|
+
icon: Icons.shield_outlined,
|
|
612
|
+
value: _ensureModelValue(
|
|
613
|
+
_fallbackModel,
|
|
614
|
+
routingModels,
|
|
615
|
+
allowAuto: false,
|
|
616
|
+
),
|
|
617
|
+
items: routingModels
|
|
618
|
+
.map(
|
|
619
|
+
(model) => DropdownMenuItem<String>(
|
|
620
|
+
value: model.id,
|
|
621
|
+
child: Text(model.label),
|
|
622
|
+
),
|
|
623
|
+
)
|
|
624
|
+
.toList(),
|
|
625
|
+
onChanged: (value) {
|
|
626
|
+
if (value != null) {
|
|
627
|
+
setState(() => _fallbackModel = value);
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
),
|
|
631
|
+
),
|
|
632
|
+
],
|
|
633
|
+
);
|
|
634
|
+
},
|
|
635
|
+
),
|
|
636
|
+
const Divider(height: 32),
|
|
637
|
+
Text(
|
|
638
|
+
'Smart Selector Pool',
|
|
639
|
+
style: TextStyle(
|
|
640
|
+
fontWeight: FontWeight.w700,
|
|
641
|
+
color: _textPrimary,
|
|
642
|
+
),
|
|
643
|
+
),
|
|
644
|
+
const SizedBox(height: 10),
|
|
645
|
+
Wrap(
|
|
646
|
+
spacing: 10,
|
|
647
|
+
runSpacing: 10,
|
|
648
|
+
children: controller.supportedModels.map((model) {
|
|
649
|
+
final selected = _enabledModels.contains(model.id);
|
|
650
|
+
return FilterChip(
|
|
651
|
+
label: Text(
|
|
652
|
+
model.available
|
|
653
|
+
? model.label
|
|
654
|
+
: '${model.label} (${model.providerStatusLabel})',
|
|
655
|
+
),
|
|
656
|
+
selected: selected,
|
|
657
|
+
selectedColor: _accentMuted,
|
|
658
|
+
checkmarkColor: _accent,
|
|
659
|
+
backgroundColor: _bgSecondary,
|
|
660
|
+
side: BorderSide(
|
|
661
|
+
color: model.available
|
|
662
|
+
? _border
|
|
663
|
+
: _warning.withValues(alpha: 0.35),
|
|
664
|
+
),
|
|
665
|
+
onSelected: model.available
|
|
666
|
+
? (value) {
|
|
667
|
+
setState(() {
|
|
668
|
+
if (value) {
|
|
669
|
+
_enabledModels.add(model.id);
|
|
670
|
+
} else if (_enabledModels.length > 1) {
|
|
671
|
+
_enabledModels.remove(model.id);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
: null,
|
|
676
|
+
);
|
|
677
|
+
}).toList(),
|
|
678
|
+
),
|
|
679
|
+
const SizedBox(height: 14),
|
|
680
|
+
Text(
|
|
681
|
+
availableModels.isEmpty
|
|
682
|
+
? 'Enable a ready provider above to unlock model routing.'
|
|
683
|
+
: '$enabledSmartModels models are currently eligible for smart routing.',
|
|
684
|
+
style: TextStyle(color: _textSecondary),
|
|
685
|
+
),
|
|
686
|
+
],
|
|
687
|
+
),
|
|
688
|
+
),
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
Widget _buildVoiceAndRecordingSection({
|
|
693
|
+
required NeoAgentController controller,
|
|
694
|
+
required List<DropdownMenuItem<String>> modelChoices,
|
|
695
|
+
required List<ModelMeta> routingModels,
|
|
696
|
+
}) {
|
|
697
|
+
final liveVoiceOptions =
|
|
698
|
+
_voiceLiveVoicesByProvider[_voiceLiveProvider] ?? const <String>[];
|
|
699
|
+
return Card(
|
|
700
|
+
child: Padding(
|
|
701
|
+
padding: const EdgeInsets.all(20),
|
|
702
|
+
child: Column(
|
|
703
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
704
|
+
children: <Widget>[
|
|
705
|
+
const _SectionTitle('Voice & Recording'),
|
|
706
|
+
const SizedBox(height: 10),
|
|
707
|
+
Text(
|
|
708
|
+
'Defaults for transcription, summaries, and live voice.',
|
|
709
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
710
|
+
),
|
|
711
|
+
const SizedBox(height: 16),
|
|
712
|
+
Text(
|
|
713
|
+
'Recording Defaults',
|
|
714
|
+
style: TextStyle(
|
|
715
|
+
fontWeight: FontWeight.w700,
|
|
716
|
+
color: _textPrimary,
|
|
717
|
+
),
|
|
718
|
+
),
|
|
719
|
+
const SizedBox(height: 12),
|
|
720
|
+
LayoutBuilder(
|
|
721
|
+
builder: (context, constraints) {
|
|
722
|
+
final compact = constraints.maxWidth < 940;
|
|
723
|
+
final cardWidth = compact
|
|
724
|
+
? constraints.maxWidth
|
|
725
|
+
: (constraints.maxWidth - 12) / 2;
|
|
726
|
+
return Wrap(
|
|
727
|
+
spacing: 12,
|
|
728
|
+
runSpacing: 12,
|
|
729
|
+
children: <Widget>[
|
|
730
|
+
SizedBox(
|
|
731
|
+
width: cardWidth,
|
|
732
|
+
child: _RoutingSelectCard(
|
|
733
|
+
label: 'Recording Summary',
|
|
734
|
+
icon: Icons.summarize_outlined,
|
|
735
|
+
value: _ensureModelValue(
|
|
736
|
+
_defaultRecordingSummaryModel,
|
|
737
|
+
routingModels,
|
|
738
|
+
allowAuto: true,
|
|
739
|
+
),
|
|
740
|
+
items: modelChoices,
|
|
741
|
+
onChanged: (value) {
|
|
742
|
+
if (value != null) {
|
|
743
|
+
setState(
|
|
744
|
+
() => _defaultRecordingSummaryModel = value,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
),
|
|
749
|
+
),
|
|
750
|
+
SizedBox(
|
|
751
|
+
width: cardWidth,
|
|
752
|
+
child: _RoutingSelectCard(
|
|
753
|
+
label: 'Recording Transcription',
|
|
754
|
+
icon: Icons.hearing_outlined,
|
|
755
|
+
value: _defaultRecordingTranscriptionModel,
|
|
756
|
+
items: _recordingTranscriptionModelChoices(
|
|
757
|
+
_defaultRecordingTranscriptionModel,
|
|
758
|
+
),
|
|
759
|
+
onChanged: (value) {
|
|
760
|
+
if (value != null) {
|
|
761
|
+
setState(() {
|
|
762
|
+
_defaultRecordingTranscriptionModel = value;
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
),
|
|
767
|
+
),
|
|
768
|
+
],
|
|
769
|
+
);
|
|
770
|
+
},
|
|
771
|
+
),
|
|
772
|
+
const Divider(height: 32),
|
|
773
|
+
Text(
|
|
774
|
+
'Speech Processing',
|
|
775
|
+
style: TextStyle(
|
|
776
|
+
fontWeight: FontWeight.w700,
|
|
777
|
+
color: _textPrimary,
|
|
778
|
+
),
|
|
779
|
+
),
|
|
780
|
+
const SizedBox(height: 12),
|
|
781
|
+
LayoutBuilder(
|
|
782
|
+
builder: (context, constraints) {
|
|
783
|
+
final compact = constraints.maxWidth < 940;
|
|
784
|
+
final cardWidth = compact
|
|
785
|
+
? constraints.maxWidth
|
|
786
|
+
: (constraints.maxWidth - 12) / 2;
|
|
787
|
+
return Wrap(
|
|
788
|
+
spacing: 12,
|
|
789
|
+
runSpacing: 12,
|
|
790
|
+
children: <Widget>[
|
|
791
|
+
SizedBox(
|
|
792
|
+
width: cardWidth,
|
|
793
|
+
child: _RoutingSelectCard(
|
|
794
|
+
label: 'Speech Model',
|
|
795
|
+
icon: Icons.record_voice_over_outlined,
|
|
796
|
+
value: _ensureModelValue(
|
|
797
|
+
_defaultSpeechModel,
|
|
798
|
+
routingModels,
|
|
799
|
+
allowAuto: true,
|
|
800
|
+
),
|
|
801
|
+
items: modelChoices,
|
|
802
|
+
onChanged: (value) {
|
|
803
|
+
if (value != null) {
|
|
804
|
+
setState(() => _defaultSpeechModel = value);
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
],
|
|
810
|
+
);
|
|
811
|
+
},
|
|
812
|
+
),
|
|
813
|
+
const SizedBox(height: 10),
|
|
814
|
+
Text(
|
|
815
|
+
'Used for the backend LLM that processes voice assistant and other speech-originated turns. This does not change the speech synthesis voice.',
|
|
816
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
817
|
+
),
|
|
818
|
+
const Divider(height: 32),
|
|
819
|
+
Text(
|
|
820
|
+
'Live Voice',
|
|
821
|
+
style: TextStyle(
|
|
822
|
+
fontWeight: FontWeight.w700,
|
|
823
|
+
color: _textPrimary,
|
|
824
|
+
),
|
|
825
|
+
),
|
|
826
|
+
const SizedBox(height: 12),
|
|
827
|
+
LayoutBuilder(
|
|
828
|
+
builder: (context, constraints) {
|
|
829
|
+
final compact = constraints.maxWidth < 940;
|
|
830
|
+
final cardWidth = compact
|
|
831
|
+
? constraints.maxWidth
|
|
832
|
+
: (constraints.maxWidth - 24) / 3;
|
|
833
|
+
return Wrap(
|
|
834
|
+
spacing: 12,
|
|
835
|
+
runSpacing: 12,
|
|
836
|
+
children: <Widget>[
|
|
837
|
+
SizedBox(
|
|
838
|
+
width: cardWidth,
|
|
839
|
+
child: _RoutingSelectCard(
|
|
840
|
+
label: 'Live Provider',
|
|
841
|
+
icon: Icons.call_outlined,
|
|
842
|
+
value: _voiceLiveProvider,
|
|
843
|
+
items: const <String>['openai', 'gemini']
|
|
844
|
+
.map(
|
|
845
|
+
(value) => DropdownMenuItem<String>(
|
|
846
|
+
value: value,
|
|
847
|
+
child: Text(value),
|
|
848
|
+
),
|
|
849
|
+
)
|
|
850
|
+
.toList(),
|
|
851
|
+
onChanged: (value) {
|
|
852
|
+
if (value == null) return;
|
|
853
|
+
setState(() {
|
|
854
|
+
_voiceLiveProvider = value;
|
|
855
|
+
final modelOptions =
|
|
856
|
+
_voiceLiveModelsByProvider[_voiceLiveProvider] ??
|
|
857
|
+
const <String>[];
|
|
858
|
+
if (!modelOptions.contains(_voiceLiveModel) &&
|
|
859
|
+
modelOptions.isNotEmpty) {
|
|
860
|
+
_voiceLiveModel = modelOptions.first;
|
|
861
|
+
}
|
|
862
|
+
final voiceOptions =
|
|
863
|
+
_voiceLiveVoicesByProvider[_voiceLiveProvider] ??
|
|
864
|
+
const <String>[];
|
|
865
|
+
if (voiceOptions.isNotEmpty &&
|
|
866
|
+
!voiceOptions.contains(_voiceLiveVoice)) {
|
|
867
|
+
_voiceLiveVoice = voiceOptions.first;
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
),
|
|
872
|
+
),
|
|
873
|
+
SizedBox(
|
|
874
|
+
width: cardWidth,
|
|
875
|
+
child: _RoutingSelectCard(
|
|
876
|
+
label: 'Live Model',
|
|
877
|
+
icon: Icons.speed_outlined,
|
|
878
|
+
value: _voiceLiveModel,
|
|
879
|
+
items:
|
|
880
|
+
(_voiceLiveModelsByProvider[_voiceLiveProvider] ??
|
|
881
|
+
const <String>[])
|
|
882
|
+
.map(
|
|
883
|
+
(value) => DropdownMenuItem<String>(
|
|
884
|
+
value: value,
|
|
885
|
+
child: Text(value),
|
|
886
|
+
),
|
|
887
|
+
)
|
|
888
|
+
.toList(),
|
|
889
|
+
onChanged: (value) {
|
|
890
|
+
if (value != null) {
|
|
891
|
+
setState(() => _voiceLiveModel = value);
|
|
892
|
+
}
|
|
893
|
+
},
|
|
894
|
+
),
|
|
895
|
+
),
|
|
896
|
+
if (liveVoiceOptions.isNotEmpty)
|
|
897
|
+
SizedBox(
|
|
898
|
+
width: cardWidth,
|
|
899
|
+
child: _RoutingSelectCard(
|
|
900
|
+
label: 'Live Voice',
|
|
901
|
+
icon: Icons.graphic_eq_outlined,
|
|
902
|
+
value: _voiceLiveVoice,
|
|
903
|
+
items: liveVoiceOptions
|
|
904
|
+
.map(
|
|
905
|
+
(value) => DropdownMenuItem<String>(
|
|
906
|
+
value: value,
|
|
907
|
+
child: Text(value),
|
|
908
|
+
),
|
|
909
|
+
)
|
|
910
|
+
.toList(),
|
|
911
|
+
onChanged: (value) {
|
|
912
|
+
if (value != null) {
|
|
913
|
+
setState(() => _voiceLiveVoice = value);
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
),
|
|
917
|
+
),
|
|
918
|
+
],
|
|
919
|
+
);
|
|
920
|
+
},
|
|
921
|
+
),
|
|
922
|
+
],
|
|
923
|
+
),
|
|
924
|
+
),
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
Widget _buildDesktopSection(NeoAgentController controller) {
|
|
929
|
+
return Card(
|
|
930
|
+
child: Padding(
|
|
931
|
+
padding: const EdgeInsets.all(20),
|
|
932
|
+
child: Column(
|
|
933
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
934
|
+
children: <Widget>[
|
|
935
|
+
const _SectionTitle('Desktop'),
|
|
936
|
+
const SizedBox(height: 10),
|
|
937
|
+
Text(
|
|
938
|
+
'Desktop-only recording and companion controls for this computer.',
|
|
939
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
940
|
+
),
|
|
941
|
+
const SizedBox(height: 16),
|
|
942
|
+
Text(
|
|
943
|
+
'Local App Behavior',
|
|
944
|
+
style: TextStyle(
|
|
945
|
+
fontWeight: FontWeight.w700,
|
|
946
|
+
color: _textPrimary,
|
|
947
|
+
),
|
|
948
|
+
),
|
|
949
|
+
const SizedBox(height: 12),
|
|
950
|
+
SwitchListTile.adaptive(
|
|
951
|
+
value: controller.desktopAskOnClose,
|
|
952
|
+
contentPadding: EdgeInsets.zero,
|
|
953
|
+
title: Text('Ask before closing to background'),
|
|
954
|
+
subtitle: Text(
|
|
955
|
+
'Prompt for whether NeoAgent should stay resident in the tray when the main window closes.',
|
|
956
|
+
style: TextStyle(color: _textSecondary),
|
|
957
|
+
),
|
|
958
|
+
onChanged: (value) => controller.setDesktopClosePreference(
|
|
959
|
+
askOnClose: value,
|
|
960
|
+
keepRunningOnClose: controller.desktopKeepRunningOnClose,
|
|
961
|
+
),
|
|
962
|
+
),
|
|
963
|
+
SwitchListTile.adaptive(
|
|
964
|
+
value: controller.desktopAutoShowFloatingToolbar,
|
|
965
|
+
contentPadding: EdgeInsets.zero,
|
|
966
|
+
title: Text('Auto-show floating toolbar'),
|
|
967
|
+
subtitle: Text(
|
|
968
|
+
'Open the compact recording bar automatically whenever a desktop studio session starts.',
|
|
969
|
+
style: TextStyle(color: _textSecondary),
|
|
970
|
+
),
|
|
971
|
+
onChanged: controller.setDesktopAutoShowFloatingToolbar,
|
|
972
|
+
),
|
|
973
|
+
SwitchListTile.adaptive(
|
|
974
|
+
value: controller.desktopAssistantHotkeyEnabled,
|
|
975
|
+
contentPadding: EdgeInsets.zero,
|
|
976
|
+
title: Text('Reserve assistant hotkey'),
|
|
977
|
+
subtitle: Text(
|
|
978
|
+
'Register $_desktopAssistantHotkeyLabel so the desktop shell is ready for the upcoming voice assistant summon flow.',
|
|
979
|
+
style: TextStyle(color: _textSecondary),
|
|
980
|
+
),
|
|
981
|
+
onChanged: controller.recordingRuntime.supportsGlobalHotkeys
|
|
982
|
+
? controller.setDesktopAssistantHotkeyEnabled
|
|
983
|
+
: null,
|
|
984
|
+
),
|
|
985
|
+
const SizedBox(height: 12),
|
|
986
|
+
Wrap(
|
|
987
|
+
spacing: 10,
|
|
988
|
+
runSpacing: 10,
|
|
989
|
+
children: <Widget>[
|
|
990
|
+
_RecordingPermissionBadge(
|
|
991
|
+
label: 'Microphone',
|
|
992
|
+
state: controller.recordingRuntime.microphonePermission,
|
|
993
|
+
),
|
|
994
|
+
_RecordingPermissionBadge(
|
|
995
|
+
label: 'System audio',
|
|
996
|
+
state: controller.recordingRuntime.systemAudioPermission,
|
|
997
|
+
),
|
|
998
|
+
],
|
|
999
|
+
),
|
|
1000
|
+
const Divider(height: 32),
|
|
1001
|
+
Text(
|
|
1002
|
+
'Companion Mode',
|
|
1003
|
+
style: TextStyle(
|
|
1004
|
+
fontWeight: FontWeight.w700,
|
|
1005
|
+
color: _textPrimary,
|
|
1006
|
+
),
|
|
1007
|
+
),
|
|
1008
|
+
const SizedBox(height: 12),
|
|
1009
|
+
SwitchListTile.adaptive(
|
|
1010
|
+
value: controller.desktopCompanionEnabled,
|
|
1011
|
+
contentPadding: EdgeInsets.zero,
|
|
1012
|
+
title: Text('Enable Companion Mode on this computer'),
|
|
1013
|
+
subtitle: Text(
|
|
1014
|
+
'Expose this signed-in desktop app as a controllable companion device without a separate pairing flow.',
|
|
1015
|
+
style: TextStyle(color: _textSecondary),
|
|
1016
|
+
),
|
|
1017
|
+
onChanged: controller.setDesktopCompanionEnabled,
|
|
1018
|
+
),
|
|
1019
|
+
SwitchListTile.adaptive(
|
|
1020
|
+
value: controller.desktopCompanionPaused,
|
|
1021
|
+
contentPadding: EdgeInsets.zero,
|
|
1022
|
+
title: Text('Pause Companion Mode'),
|
|
1023
|
+
subtitle: Text(
|
|
1024
|
+
'Keep the device registered but reject remote control commands locally until resumed.',
|
|
1025
|
+
style: TextStyle(color: _textSecondary),
|
|
1026
|
+
),
|
|
1027
|
+
onChanged: controller.desktopCompanionEnabled
|
|
1028
|
+
? controller.setDesktopCompanionPaused
|
|
1029
|
+
: null,
|
|
1030
|
+
),
|
|
1031
|
+
const SizedBox(height: 12),
|
|
1032
|
+
TextFormField(
|
|
1033
|
+
initialValue: controller.desktopCompanionLabel,
|
|
1034
|
+
enabled: controller.desktopCompanionEnabled,
|
|
1035
|
+
decoration: const InputDecoration(
|
|
1036
|
+
labelText: 'Companion device label',
|
|
1037
|
+
hintText: 'My workstation',
|
|
1038
|
+
prefixIcon: Icon(Icons.edit_outlined),
|
|
1039
|
+
),
|
|
1040
|
+
onFieldSubmitted: controller.setDesktopCompanionLabel,
|
|
1041
|
+
),
|
|
1042
|
+
const SizedBox(height: 14),
|
|
1043
|
+
Wrap(
|
|
1044
|
+
spacing: 10,
|
|
1045
|
+
runSpacing: 10,
|
|
1046
|
+
children: <Widget>[
|
|
1047
|
+
_DotStatus(
|
|
1048
|
+
label: controller.desktopCompanionConnected
|
|
1049
|
+
? 'Connected'
|
|
1050
|
+
: controller.desktopCompanionConnecting
|
|
1051
|
+
? 'Connecting'
|
|
1052
|
+
: 'Disconnected',
|
|
1053
|
+
color: controller.desktopCompanionConnected
|
|
1054
|
+
? _success
|
|
1055
|
+
: controller.desktopCompanionConnecting
|
|
1056
|
+
? _accent
|
|
1057
|
+
: _warning,
|
|
1058
|
+
),
|
|
1059
|
+
_DotStatus(
|
|
1060
|
+
label: controller.desktopCompanionPaused ? 'Paused' : 'Ready',
|
|
1061
|
+
color: controller.desktopCompanionPaused
|
|
1062
|
+
? _warning
|
|
1063
|
+
: _success,
|
|
1064
|
+
),
|
|
1065
|
+
],
|
|
1066
|
+
),
|
|
1067
|
+
if (controller.desktopCompanionErrorMessage
|
|
1068
|
+
case final message?) ...<Widget>[
|
|
1069
|
+
const SizedBox(height: 12),
|
|
1070
|
+
_InlineError(message: message),
|
|
1071
|
+
],
|
|
1072
|
+
const SizedBox(height: 14),
|
|
1073
|
+
Builder(
|
|
1074
|
+
builder: (context) {
|
|
1075
|
+
final status = controller.desktopCompanionStatus;
|
|
1076
|
+
final permissionsRaw = status['permissions'];
|
|
1077
|
+
final permissions = permissionsRaw is Map
|
|
1078
|
+
? permissionsRaw.map(
|
|
1079
|
+
(key, value) => MapEntry(
|
|
1080
|
+
key.toString(),
|
|
1081
|
+
value?.toString() ?? 'unknown',
|
|
1082
|
+
),
|
|
1083
|
+
)
|
|
1084
|
+
: const <String, String>{};
|
|
1085
|
+
final screenCaptureState =
|
|
1086
|
+
permissions['screenCapture'] ?? 'unknown';
|
|
1087
|
+
final inputControlState =
|
|
1088
|
+
permissions['inputControl'] ?? 'unknown';
|
|
1089
|
+
final accessibilityState =
|
|
1090
|
+
permissions['accessibility'] ?? 'unknown';
|
|
1091
|
+
final grantHelp = switch (defaultTargetPlatform) {
|
|
1092
|
+
TargetPlatform.macOS =>
|
|
1093
|
+
'Grant Screen Recording and Accessibility in System Settings, then press Re-check.',
|
|
1094
|
+
TargetPlatform.windows =>
|
|
1095
|
+
'Grant capture and accessibility/input permissions in Windows Settings, then press Re-check.',
|
|
1096
|
+
TargetPlatform.linux =>
|
|
1097
|
+
'Approve portal capture/input prompts and desktop accessibility access, then press Re-check.',
|
|
1098
|
+
TargetPlatform.android ||
|
|
1099
|
+
TargetPlatform.iOS ||
|
|
1100
|
+
TargetPlatform.fuchsia =>
|
|
1101
|
+
'Desktop companion permission controls are unavailable on this platform.',
|
|
1102
|
+
};
|
|
1103
|
+
return Column(
|
|
1104
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1105
|
+
children: <Widget>[
|
|
1106
|
+
Text(
|
|
1107
|
+
'Permissions',
|
|
1108
|
+
style: TextStyle(
|
|
1109
|
+
fontWeight: FontWeight.w700,
|
|
1110
|
+
color: _textPrimary,
|
|
1111
|
+
),
|
|
1112
|
+
),
|
|
1113
|
+
const SizedBox(height: 8),
|
|
1114
|
+
Text(
|
|
1115
|
+
grantHelp,
|
|
1116
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
1117
|
+
),
|
|
1118
|
+
const SizedBox(height: 10),
|
|
1119
|
+
Wrap(
|
|
1120
|
+
spacing: 10,
|
|
1121
|
+
runSpacing: 10,
|
|
1122
|
+
children: <Widget>[
|
|
1123
|
+
_CompanionPermissionBadge(
|
|
1124
|
+
label: 'Screen capture',
|
|
1125
|
+
state: screenCaptureState,
|
|
1126
|
+
),
|
|
1127
|
+
_CompanionPermissionBadge(
|
|
1128
|
+
label: 'Input control',
|
|
1129
|
+
state: inputControlState,
|
|
1130
|
+
),
|
|
1131
|
+
_CompanionPermissionBadge(
|
|
1132
|
+
label: 'Accessibility',
|
|
1133
|
+
state: accessibilityState,
|
|
1134
|
+
),
|
|
1135
|
+
],
|
|
1136
|
+
),
|
|
1137
|
+
const SizedBox(height: 12),
|
|
1138
|
+
Wrap(
|
|
1139
|
+
spacing: 10,
|
|
1140
|
+
runSpacing: 10,
|
|
1141
|
+
children: <Widget>[
|
|
1142
|
+
OutlinedButton.icon(
|
|
1143
|
+
onPressed: controller.desktopCompanionEnabled
|
|
1144
|
+
? controller.refreshDesktopCompanionStatus
|
|
1145
|
+
: null,
|
|
1146
|
+
icon: Icon(Icons.sync_outlined),
|
|
1147
|
+
label: Text('Re-check permissions'),
|
|
1148
|
+
),
|
|
1149
|
+
OutlinedButton.icon(
|
|
1150
|
+
onPressed: controller.desktopCompanionEnabled
|
|
1151
|
+
? () => controller
|
|
1152
|
+
.openDesktopCompanionPermissionSettings(
|
|
1153
|
+
'screenCapture',
|
|
1154
|
+
)
|
|
1155
|
+
: null,
|
|
1156
|
+
icon: Icon(Icons.monitor_outlined),
|
|
1157
|
+
label: Text('Open capture settings'),
|
|
1158
|
+
),
|
|
1159
|
+
OutlinedButton.icon(
|
|
1160
|
+
onPressed: controller.desktopCompanionEnabled
|
|
1161
|
+
? () => controller
|
|
1162
|
+
.openDesktopCompanionPermissionSettings(
|
|
1163
|
+
'accessibility',
|
|
1164
|
+
)
|
|
1165
|
+
: null,
|
|
1166
|
+
icon: Icon(Icons.keyboard_command_key_outlined),
|
|
1167
|
+
label: Text('Open input/access settings'),
|
|
1168
|
+
),
|
|
1169
|
+
OutlinedButton.icon(
|
|
1170
|
+
onPressed: controller.desktopCompanionEnabled
|
|
1171
|
+
? controller.rotateDesktopCompanionIdentity
|
|
1172
|
+
: null,
|
|
1173
|
+
icon: Icon(Icons.refresh_outlined),
|
|
1174
|
+
label: Text('Reset Device Identity'),
|
|
1175
|
+
),
|
|
1176
|
+
],
|
|
1177
|
+
),
|
|
1178
|
+
],
|
|
1179
|
+
);
|
|
1180
|
+
},
|
|
1181
|
+
),
|
|
1182
|
+
],
|
|
1183
|
+
),
|
|
1184
|
+
),
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
Widget _buildUpdatesSection(NeoAgentController controller) {
|
|
1189
|
+
return Card(
|
|
1190
|
+
child: Padding(
|
|
1191
|
+
padding: const EdgeInsets.all(20),
|
|
1192
|
+
child: Column(
|
|
1193
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1194
|
+
children: <Widget>[
|
|
1195
|
+
const _SectionTitle('Updates'),
|
|
1196
|
+
const SizedBox(height: 10),
|
|
1197
|
+
Text(
|
|
1198
|
+
'Client and runtime update controls live here.',
|
|
1199
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
1200
|
+
),
|
|
1201
|
+
const SizedBox(height: 16),
|
|
1202
|
+
LayoutBuilder(
|
|
1203
|
+
builder: (context, constraints) {
|
|
1204
|
+
final compact = constraints.maxWidth < 720;
|
|
1205
|
+
final checkButton = FilledButton.icon(
|
|
1206
|
+
onPressed:
|
|
1207
|
+
controller.isCheckingAppUpdate ||
|
|
1208
|
+
!controller.appUpdaterConfigured
|
|
1209
|
+
? null
|
|
1210
|
+
: () => controller.checkForAppUpdates(),
|
|
1211
|
+
style: FilledButton.styleFrom(backgroundColor: _accent),
|
|
1212
|
+
icon: controller.isCheckingAppUpdate
|
|
1213
|
+
? const SizedBox.square(
|
|
1214
|
+
dimension: 16,
|
|
1215
|
+
child: CircularProgressIndicator(
|
|
1216
|
+
strokeWidth: 2,
|
|
1217
|
+
color: Colors.white,
|
|
1218
|
+
),
|
|
1219
|
+
)
|
|
1220
|
+
: const Icon(Icons.sync),
|
|
1221
|
+
label: Text(
|
|
1222
|
+
controller.isCheckingAppUpdate
|
|
1223
|
+
? 'Checking...'
|
|
1224
|
+
: 'Check now',
|
|
1225
|
+
),
|
|
1226
|
+
);
|
|
1227
|
+
final appHeading = Text(
|
|
1228
|
+
'Client App',
|
|
1229
|
+
style: TextStyle(
|
|
1230
|
+
fontWeight: FontWeight.w700,
|
|
1231
|
+
color: _textPrimary,
|
|
1232
|
+
),
|
|
1233
|
+
);
|
|
1234
|
+
if (compact) {
|
|
1235
|
+
return Column(
|
|
1236
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1237
|
+
children: <Widget>[
|
|
1238
|
+
appHeading,
|
|
1239
|
+
const SizedBox(height: 10),
|
|
1240
|
+
checkButton,
|
|
1241
|
+
],
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
return Row(
|
|
1245
|
+
children: <Widget>[
|
|
1246
|
+
Expanded(child: appHeading),
|
|
1247
|
+
checkButton,
|
|
1248
|
+
],
|
|
1249
|
+
);
|
|
1250
|
+
},
|
|
1251
|
+
),
|
|
1252
|
+
const SizedBox(height: 12),
|
|
1253
|
+
if (!controller.appUpdaterConfigured)
|
|
1254
|
+
Text(
|
|
1255
|
+
kIsWeb
|
|
1256
|
+
? 'Client app update checks are disabled in the web app to avoid blocked browser-side GitHub requests.'
|
|
1257
|
+
: 'Client app updates are not configured for this build.',
|
|
1258
|
+
style: TextStyle(color: _textSecondary, height: 1.5),
|
|
1259
|
+
)
|
|
1260
|
+
else ...<Widget>[
|
|
1261
|
+
LayoutBuilder(
|
|
1262
|
+
builder: (context, constraints) {
|
|
1263
|
+
final compact = constraints.maxWidth < 780;
|
|
1264
|
+
final channelPicker = DropdownButtonFormField<String>(
|
|
1265
|
+
initialValue: controller.appUpdateChannel,
|
|
1266
|
+
decoration: const InputDecoration(
|
|
1267
|
+
labelText: 'App release channel',
|
|
1268
|
+
),
|
|
1269
|
+
items: const <DropdownMenuItem<String>>[
|
|
1270
|
+
DropdownMenuItem<String>(
|
|
1271
|
+
value: 'stable',
|
|
1272
|
+
child: Text('Stable'),
|
|
1273
|
+
),
|
|
1274
|
+
DropdownMenuItem<String>(
|
|
1275
|
+
value: 'beta',
|
|
1276
|
+
child: Text('Beta'),
|
|
1277
|
+
),
|
|
1278
|
+
],
|
|
1279
|
+
onChanged: (value) {
|
|
1280
|
+
if (value != null) {
|
|
1281
|
+
unawaited(controller.setAppUpdateChannel(value));
|
|
1282
|
+
}
|
|
1283
|
+
},
|
|
1284
|
+
);
|
|
1285
|
+
final autoCheck = SwitchListTile.adaptive(
|
|
1286
|
+
value: controller.appUpdateAutoCheckEnabled,
|
|
1287
|
+
contentPadding: EdgeInsets.zero,
|
|
1288
|
+
title: Text('Check automatically on launch'),
|
|
1289
|
+
subtitle: Text(
|
|
1290
|
+
'This only checks GitHub Releases on startup. Installation still requires your confirmation.',
|
|
1291
|
+
style: TextStyle(color: _textSecondary),
|
|
1292
|
+
),
|
|
1293
|
+
onChanged: controller.setAppUpdateAutoCheckEnabled,
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
if (compact) {
|
|
1297
|
+
return Column(
|
|
1298
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1299
|
+
children: <Widget>[
|
|
1300
|
+
channelPicker,
|
|
1301
|
+
const SizedBox(height: 10),
|
|
1302
|
+
autoCheck,
|
|
1303
|
+
],
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return Row(
|
|
1308
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1309
|
+
children: <Widget>[
|
|
1310
|
+
Expanded(child: channelPicker),
|
|
1311
|
+
const SizedBox(width: 16),
|
|
1312
|
+
Expanded(child: autoCheck),
|
|
1313
|
+
],
|
|
1314
|
+
);
|
|
1315
|
+
},
|
|
1316
|
+
),
|
|
1317
|
+
const SizedBox(height: 8),
|
|
1318
|
+
Text(
|
|
1319
|
+
'Installed: ${controller.installedAppVersion ?? 'Unknown'} | Channel: ${controller.appUpdateChannelLabel} | Last checked: ${controller.appUpdateLastCheckedLabel}',
|
|
1320
|
+
style: TextStyle(color: _textSecondary),
|
|
1321
|
+
),
|
|
1322
|
+
const SizedBox(height: 6),
|
|
1323
|
+
Text(
|
|
1324
|
+
'Source: ${app_release_updater.appUpdaterGithubOwner}/${app_release_updater.appUpdaterGithubRepo}${app_release_updater.appUpdaterGithubToken.trim().isNotEmpty ? ' (override active)' : ''}',
|
|
1325
|
+
style: TextStyle(color: _textSecondary),
|
|
1326
|
+
),
|
|
1327
|
+
if (controller.appUpdateErrorMessage
|
|
1328
|
+
case final message?) ...<Widget>[
|
|
1329
|
+
const SizedBox(height: 12),
|
|
1330
|
+
_InlineError(message: message),
|
|
1331
|
+
],
|
|
1332
|
+
if (controller.availableAppUpdate
|
|
1333
|
+
case final release?) ...<Widget>[
|
|
1334
|
+
const SizedBox(height: 16),
|
|
1335
|
+
Container(
|
|
1336
|
+
width: double.infinity,
|
|
1337
|
+
padding: const EdgeInsets.all(16),
|
|
1338
|
+
decoration: BoxDecoration(
|
|
1339
|
+
color: _bgSecondary,
|
|
1340
|
+
borderRadius: BorderRadius.circular(18),
|
|
1341
|
+
border: Border.all(color: _border),
|
|
1342
|
+
),
|
|
1343
|
+
child: Column(
|
|
1344
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1345
|
+
children: <Widget>[
|
|
1346
|
+
Wrap(
|
|
1347
|
+
spacing: 10,
|
|
1348
|
+
runSpacing: 10,
|
|
1349
|
+
children: <Widget>[
|
|
1350
|
+
_StatusPill(
|
|
1351
|
+
label: 'Update ${release.version}',
|
|
1352
|
+
color: release.channel == 'beta'
|
|
1353
|
+
? _warning
|
|
1354
|
+
: _accent,
|
|
1355
|
+
),
|
|
1356
|
+
_StatusPill(
|
|
1357
|
+
label: release.asset.name,
|
|
1358
|
+
color: _textSecondary,
|
|
1359
|
+
),
|
|
1360
|
+
],
|
|
1361
|
+
),
|
|
1362
|
+
const SizedBox(height: 10),
|
|
1363
|
+
Text(
|
|
1364
|
+
'${release.title} · ${release.publishedLabel} · ${release.asset.sizeLabel}',
|
|
1365
|
+
style: TextStyle(color: _textSecondary),
|
|
1366
|
+
),
|
|
1367
|
+
if (release.body.trim().isNotEmpty) ...<Widget>[
|
|
1368
|
+
const SizedBox(height: 14),
|
|
1369
|
+
ConstrainedBox(
|
|
1370
|
+
constraints: const BoxConstraints(maxHeight: 220),
|
|
1371
|
+
child: SingleChildScrollView(
|
|
1372
|
+
child: MarkdownBody(
|
|
1373
|
+
data: release.body,
|
|
1374
|
+
selectable: true,
|
|
1375
|
+
styleSheet: MarkdownStyleSheet(
|
|
1376
|
+
p: TextStyle(
|
|
1377
|
+
color: _textSecondary,
|
|
1378
|
+
height: 1.45,
|
|
1379
|
+
),
|
|
1380
|
+
),
|
|
1381
|
+
),
|
|
1382
|
+
),
|
|
1383
|
+
),
|
|
1384
|
+
],
|
|
1385
|
+
const SizedBox(height: 14),
|
|
1386
|
+
Wrap(
|
|
1387
|
+
spacing: 10,
|
|
1388
|
+
runSpacing: 10,
|
|
1389
|
+
children: <Widget>[
|
|
1390
|
+
FilledButton.icon(
|
|
1391
|
+
onPressed: controller.isOpeningAppUpdate
|
|
1392
|
+
? null
|
|
1393
|
+
: controller.openAppUpdate,
|
|
1394
|
+
style: FilledButton.styleFrom(
|
|
1395
|
+
backgroundColor: _accent,
|
|
1396
|
+
),
|
|
1397
|
+
icon: controller.isOpeningAppUpdate
|
|
1398
|
+
? const SizedBox.square(
|
|
1399
|
+
dimension: 16,
|
|
1400
|
+
child: CircularProgressIndicator(
|
|
1401
|
+
strokeWidth: 2,
|
|
1402
|
+
color: Colors.white,
|
|
1403
|
+
),
|
|
1404
|
+
)
|
|
1405
|
+
: const Icon(Icons.system_update_alt),
|
|
1406
|
+
label: Text(
|
|
1407
|
+
controller.isOpeningAppUpdate
|
|
1408
|
+
? 'Opening...'
|
|
1409
|
+
: 'Download update',
|
|
1410
|
+
),
|
|
1411
|
+
),
|
|
1412
|
+
if (release.htmlUrl.trim().isNotEmpty)
|
|
1413
|
+
OutlinedButton.icon(
|
|
1414
|
+
onPressed: () {
|
|
1415
|
+
unawaited(
|
|
1416
|
+
widget.controller._oauthLauncher.openExternal(
|
|
1417
|
+
url: release.htmlUrl,
|
|
1418
|
+
label: 'release_notes',
|
|
1419
|
+
),
|
|
1420
|
+
);
|
|
1421
|
+
},
|
|
1422
|
+
icon: const Icon(Icons.open_in_new),
|
|
1423
|
+
label: Text('View release'),
|
|
1424
|
+
),
|
|
1425
|
+
],
|
|
1426
|
+
),
|
|
1427
|
+
],
|
|
1428
|
+
),
|
|
1429
|
+
),
|
|
1430
|
+
] else ...<Widget>[
|
|
1431
|
+
const SizedBox(height: 12),
|
|
1432
|
+
Text(
|
|
1433
|
+
controller.isCheckingAppUpdate
|
|
1434
|
+
? 'Checking GitHub releases...'
|
|
1435
|
+
: controller.appUpdateLastCheckedAt == null
|
|
1436
|
+
? 'Choose a channel, then check GitHub releases.'
|
|
1437
|
+
: 'No newer app release is available on the selected channel.',
|
|
1438
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
1439
|
+
),
|
|
1440
|
+
],
|
|
1441
|
+
],
|
|
1442
|
+
const Divider(height: 32),
|
|
1443
|
+
if (controller.updateStatus.allowSelfUpdate) ...<Widget>[
|
|
1444
|
+
LayoutBuilder(
|
|
1445
|
+
builder: (context, constraints) {
|
|
1446
|
+
final compact = constraints.maxWidth < 780;
|
|
1447
|
+
final channelPicker = DropdownButtonFormField<String>(
|
|
1448
|
+
initialValue: controller.updateStatus.releaseChannel,
|
|
1449
|
+
decoration: const InputDecoration(
|
|
1450
|
+
labelText: 'Runtime release channel',
|
|
1451
|
+
),
|
|
1452
|
+
items: const <DropdownMenuItem<String>>[
|
|
1453
|
+
DropdownMenuItem<String>(
|
|
1454
|
+
value: 'stable',
|
|
1455
|
+
child: Text('Stable'),
|
|
1456
|
+
),
|
|
1457
|
+
DropdownMenuItem<String>(
|
|
1458
|
+
value: 'beta',
|
|
1459
|
+
child: Text('Beta'),
|
|
1460
|
+
),
|
|
1461
|
+
],
|
|
1462
|
+
onChanged:
|
|
1463
|
+
controller.isSavingReleaseChannel ||
|
|
1464
|
+
controller.isTriggeringUpdate ||
|
|
1465
|
+
controller.updateStatus.state == 'running'
|
|
1466
|
+
? null
|
|
1467
|
+
: (value) {
|
|
1468
|
+
if (value != null) {
|
|
1469
|
+
unawaited(controller.setReleaseChannel(value));
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
);
|
|
1473
|
+
|
|
1474
|
+
final channelHelper = Text(
|
|
1475
|
+
controller.updateStatus.releaseChannel == 'beta'
|
|
1476
|
+
? 'Beta follows preview releases.'
|
|
1477
|
+
: 'Stable follows production releases.',
|
|
1478
|
+
style: TextStyle(color: _textSecondary),
|
|
1479
|
+
);
|
|
1480
|
+
|
|
1481
|
+
if (compact) {
|
|
1482
|
+
return Column(
|
|
1483
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1484
|
+
children: <Widget>[
|
|
1485
|
+
channelPicker,
|
|
1486
|
+
const SizedBox(height: 8),
|
|
1487
|
+
channelHelper,
|
|
1488
|
+
const SizedBox(height: 16),
|
|
1489
|
+
],
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
return Padding(
|
|
1494
|
+
padding: const EdgeInsets.only(bottom: 16),
|
|
1495
|
+
child: Row(
|
|
1496
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1497
|
+
children: <Widget>[
|
|
1498
|
+
Expanded(child: channelPicker),
|
|
1499
|
+
const SizedBox(width: 12),
|
|
1500
|
+
Expanded(child: channelHelper),
|
|
1501
|
+
],
|
|
1502
|
+
),
|
|
1503
|
+
);
|
|
1504
|
+
},
|
|
1505
|
+
),
|
|
1506
|
+
LayoutBuilder(
|
|
1507
|
+
builder: (context, constraints) {
|
|
1508
|
+
final compact = constraints.maxWidth < 780;
|
|
1509
|
+
final runtimeTitle = Text(
|
|
1510
|
+
'Runtime',
|
|
1511
|
+
style: TextStyle(
|
|
1512
|
+
fontWeight: FontWeight.w700,
|
|
1513
|
+
color: _textPrimary,
|
|
1514
|
+
),
|
|
1515
|
+
);
|
|
1516
|
+
final updateButton = FilledButton.icon(
|
|
1517
|
+
onPressed:
|
|
1518
|
+
controller.isSavingReleaseChannel ||
|
|
1519
|
+
controller.isTriggeringUpdate ||
|
|
1520
|
+
controller.updateStatus.state == 'running'
|
|
1521
|
+
? null
|
|
1522
|
+
: controller.triggerUpdate,
|
|
1523
|
+
style: FilledButton.styleFrom(backgroundColor: _accent),
|
|
1524
|
+
icon: controller.isTriggeringUpdate
|
|
1525
|
+
? const SizedBox.square(
|
|
1526
|
+
dimension: 16,
|
|
1527
|
+
child: CircularProgressIndicator(
|
|
1528
|
+
strokeWidth: 2,
|
|
1529
|
+
color: Colors.white,
|
|
1530
|
+
),
|
|
1531
|
+
)
|
|
1532
|
+
: Icon(Icons.system_update),
|
|
1533
|
+
label: Text('Update'),
|
|
1534
|
+
);
|
|
1535
|
+
if (compact) {
|
|
1536
|
+
return Column(
|
|
1537
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1538
|
+
children: <Widget>[
|
|
1539
|
+
runtimeTitle,
|
|
1540
|
+
const SizedBox(height: 10),
|
|
1541
|
+
updateButton,
|
|
1542
|
+
],
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
return Row(
|
|
1546
|
+
children: <Widget>[
|
|
1547
|
+
Expanded(child: runtimeTitle),
|
|
1548
|
+
updateButton,
|
|
1549
|
+
],
|
|
1550
|
+
);
|
|
1551
|
+
},
|
|
1552
|
+
),
|
|
1553
|
+
] else ...<Widget>[
|
|
1554
|
+
Text(
|
|
1555
|
+
'Runtime',
|
|
1556
|
+
style: TextStyle(
|
|
1557
|
+
fontWeight: FontWeight.w700,
|
|
1558
|
+
color: _textPrimary,
|
|
1559
|
+
),
|
|
1560
|
+
),
|
|
1561
|
+
const SizedBox(height: 10),
|
|
1562
|
+
Text(
|
|
1563
|
+
'Updates and release tracks are managed for this deployment.',
|
|
1564
|
+
style: TextStyle(color: _textSecondary),
|
|
1565
|
+
),
|
|
1566
|
+
],
|
|
1567
|
+
const SizedBox(height: 12),
|
|
1568
|
+
LayoutBuilder(
|
|
1569
|
+
builder: (context, constraints) {
|
|
1570
|
+
final compact = constraints.maxWidth < 760;
|
|
1571
|
+
final statusRow = Wrap(
|
|
1572
|
+
spacing: 10,
|
|
1573
|
+
runSpacing: 10,
|
|
1574
|
+
crossAxisAlignment: WrapCrossAlignment.center,
|
|
1575
|
+
children: <Widget>[
|
|
1576
|
+
_StatusPill(
|
|
1577
|
+
label: controller.updateStatus.badgeLabel,
|
|
1578
|
+
color: controller.updateStatus.badgeColor,
|
|
1579
|
+
),
|
|
1580
|
+
_StatusPill(
|
|
1581
|
+
label: controller.updateStatus.releaseChannelLabel,
|
|
1582
|
+
color: controller.updateStatus.releaseChannel == 'beta'
|
|
1583
|
+
? _warning
|
|
1584
|
+
: _accent,
|
|
1585
|
+
),
|
|
1586
|
+
Text(
|
|
1587
|
+
controller.updateStatus.message,
|
|
1588
|
+
style: TextStyle(color: _textSecondary),
|
|
1589
|
+
),
|
|
1590
|
+
Text('${controller.updateStatus.progress}%'),
|
|
1591
|
+
],
|
|
1592
|
+
);
|
|
1593
|
+
if (compact) {
|
|
1594
|
+
return Column(
|
|
1595
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1596
|
+
children: <Widget>[statusRow],
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
return statusRow;
|
|
1600
|
+
},
|
|
1601
|
+
),
|
|
1602
|
+
const SizedBox(height: 10),
|
|
1603
|
+
ClipRRect(
|
|
1604
|
+
borderRadius: BorderRadius.circular(999),
|
|
1605
|
+
child: LinearProgressIndicator(
|
|
1606
|
+
minHeight: 8,
|
|
1607
|
+
value: controller.updateStatus.progress / 100,
|
|
1608
|
+
backgroundColor: _bgSecondary,
|
|
1609
|
+
color: _accent,
|
|
1610
|
+
),
|
|
1611
|
+
),
|
|
1612
|
+
const SizedBox(height: 12),
|
|
1613
|
+
Text(controller.updateStatus.versionLine),
|
|
1614
|
+
],
|
|
1615
|
+
),
|
|
1616
|
+
),
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
Widget _buildDiagnosticsSection(NeoAgentController controller) {
|
|
1621
|
+
return Card(
|
|
1622
|
+
child: Padding(
|
|
1623
|
+
padding: const EdgeInsets.all(20),
|
|
1624
|
+
child: Column(
|
|
1625
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1626
|
+
children: <Widget>[
|
|
1627
|
+
Row(
|
|
1628
|
+
children: <Widget>[
|
|
1629
|
+
const _SectionTitle('Diagnostics'),
|
|
1630
|
+
const SizedBox(width: 8),
|
|
1631
|
+
Icon(Icons.info_outline, size: 16, color: _textSecondary),
|
|
1632
|
+
],
|
|
1633
|
+
),
|
|
1634
|
+
const SizedBox(height: 10),
|
|
1635
|
+
Text(
|
|
1636
|
+
'Usage and health signals that help explain current runtime behavior without digging through logs first.',
|
|
1637
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
1638
|
+
),
|
|
1639
|
+
const SizedBox(height: 14),
|
|
1640
|
+
if (controller.tokenUsage == null)
|
|
1641
|
+
Text(
|
|
1642
|
+
'Token usage unavailable on this server version.',
|
|
1643
|
+
style: TextStyle(color: _textSecondary),
|
|
1644
|
+
)
|
|
1645
|
+
else
|
|
1646
|
+
Column(
|
|
1647
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1648
|
+
children: <Widget>[
|
|
1649
|
+
Text(
|
|
1650
|
+
'Total: ${controller.tokenUsage!.totalTokensLabel} tokens across ${controller.tokenUsage!.totalRunsLabel} runs',
|
|
1651
|
+
),
|
|
1652
|
+
const SizedBox(height: 6),
|
|
1653
|
+
Text(
|
|
1654
|
+
'Last 7 days: ${controller.tokenUsage!.last7DaysTokensLabel} tokens in ${controller.tokenUsage!.last7DaysRunsLabel} runs',
|
|
1655
|
+
),
|
|
1656
|
+
const SizedBox(height: 6),
|
|
1657
|
+
Text(
|
|
1658
|
+
'Avg/run: ${controller.tokenUsage!.avgTokensPerRunLabel} tokens',
|
|
1659
|
+
),
|
|
1660
|
+
],
|
|
1661
|
+
),
|
|
1662
|
+
],
|
|
1663
|
+
),
|
|
1664
|
+
),
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
String _providerForSelectedModel(String modelId, List<ModelMeta> models) {
|
|
1669
|
+
if (modelId.trim().isEmpty || modelId == 'auto') {
|
|
1670
|
+
return 'auto';
|
|
1671
|
+
}
|
|
1672
|
+
for (final model in models) {
|
|
1673
|
+
if (model.id == modelId) {
|
|
1674
|
+
return model.provider.trim().isEmpty ? 'auto' : model.provider;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return 'auto';
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
Map<String, dynamic> _buildProviderPayload() {
|
|
1681
|
+
final providerIds = <String>{
|
|
1682
|
+
...widget.controller.aiProviders.map((provider) => provider.id),
|
|
1683
|
+
...widget.controller.aiProviderConfigs.keys,
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
return <String, dynamic>{
|
|
1687
|
+
for (final providerId in providerIds)
|
|
1688
|
+
providerId: <String, dynamic>{
|
|
1689
|
+
'enabled':
|
|
1690
|
+
_providerEnabled[providerId] ??
|
|
1691
|
+
widget.controller.aiProviderConfigs[providerId]?.enabled ??
|
|
1692
|
+
true,
|
|
1693
|
+
'baseUrl': _providerBaseUrlControllers[providerId]?.text.trim() ?? '',
|
|
1694
|
+
},
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
void _syncTextController(
|
|
1699
|
+
Map<String, TextEditingController> controllers,
|
|
1700
|
+
String id,
|
|
1701
|
+
String value,
|
|
1702
|
+
) {
|
|
1703
|
+
final controller = controllers.putIfAbsent(
|
|
1704
|
+
id,
|
|
1705
|
+
() => TextEditingController(text: value),
|
|
1706
|
+
);
|
|
1707
|
+
if (controller.text != value) {
|
|
1708
|
+
controller.text = value;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
void _pruneControllers(
|
|
1713
|
+
Map<String, TextEditingController> controllers,
|
|
1714
|
+
Set<String> activeIds,
|
|
1715
|
+
) {
|
|
1716
|
+
final staleIds = controllers.keys
|
|
1717
|
+
.where((id) => !activeIds.contains(id))
|
|
1718
|
+
.toList();
|
|
1719
|
+
for (final id in staleIds) {
|
|
1720
|
+
controllers.remove(id)?.dispose();
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
List<DropdownMenuItem<String>> _recordingTranscriptionModelChoices(
|
|
1725
|
+
String current,
|
|
1726
|
+
) {
|
|
1727
|
+
const defaults = <String>['nova-3', 'nova-2-general'];
|
|
1728
|
+
final normalizedCurrent = current.trim();
|
|
1729
|
+
final values = <String>{...defaults};
|
|
1730
|
+
if (normalizedCurrent.isNotEmpty) {
|
|
1731
|
+
values.add(normalizedCurrent);
|
|
1732
|
+
}
|
|
1733
|
+
return values
|
|
1734
|
+
.map(
|
|
1735
|
+
(value) => DropdownMenuItem<String>(value: value, child: Text(value)),
|
|
1736
|
+
)
|
|
1737
|
+
.toList();
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
class _AiProviderCard extends StatelessWidget {
|
|
1742
|
+
const _AiProviderCard({
|
|
1743
|
+
required this.provider,
|
|
1744
|
+
required this.enabled,
|
|
1745
|
+
required this.expanded,
|
|
1746
|
+
required this.models,
|
|
1747
|
+
required this.baseUrlController,
|
|
1748
|
+
required this.onEnabledChanged,
|
|
1749
|
+
required this.onExpandToggle,
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
final AiProviderMeta provider;
|
|
1753
|
+
final bool enabled;
|
|
1754
|
+
final bool expanded;
|
|
1755
|
+
final List<ModelMeta> models;
|
|
1756
|
+
final TextEditingController baseUrlController;
|
|
1757
|
+
final ValueChanged<bool> onEnabledChanged;
|
|
1758
|
+
final VoidCallback onExpandToggle;
|
|
1759
|
+
|
|
1760
|
+
@override
|
|
1761
|
+
Widget build(BuildContext context) {
|
|
1762
|
+
final availableCount = models.where((model) => model.available).length;
|
|
1763
|
+
final hasAdvancedFields = provider.supportsBaseUrl || models.isNotEmpty;
|
|
1764
|
+
return Container(
|
|
1765
|
+
padding: const EdgeInsets.all(16),
|
|
1766
|
+
decoration: BoxDecoration(
|
|
1767
|
+
color: _bgSecondary,
|
|
1768
|
+
borderRadius: BorderRadius.circular(18),
|
|
1769
|
+
border: Border.all(color: _borderLight),
|
|
1770
|
+
),
|
|
1771
|
+
child: Column(
|
|
1772
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1773
|
+
children: <Widget>[
|
|
1774
|
+
Row(
|
|
1775
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1776
|
+
children: <Widget>[
|
|
1777
|
+
Container(
|
|
1778
|
+
width: 44,
|
|
1779
|
+
height: 44,
|
|
1780
|
+
decoration: BoxDecoration(
|
|
1781
|
+
color: _accentMuted,
|
|
1782
|
+
borderRadius: BorderRadius.circular(14),
|
|
1783
|
+
),
|
|
1784
|
+
child: Icon(provider.icon, color: _accentHover),
|
|
1785
|
+
),
|
|
1786
|
+
const SizedBox(width: 12),
|
|
1787
|
+
Expanded(
|
|
1788
|
+
child: Column(
|
|
1789
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1790
|
+
children: <Widget>[
|
|
1791
|
+
Text(
|
|
1792
|
+
provider.label,
|
|
1793
|
+
style: TextStyle(
|
|
1794
|
+
fontSize: 16,
|
|
1795
|
+
fontWeight: FontWeight.w700,
|
|
1796
|
+
),
|
|
1797
|
+
),
|
|
1798
|
+
const SizedBox(height: 4),
|
|
1799
|
+
Text(
|
|
1800
|
+
provider.description,
|
|
1801
|
+
maxLines: 2,
|
|
1802
|
+
overflow: TextOverflow.ellipsis,
|
|
1803
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
1804
|
+
),
|
|
1805
|
+
],
|
|
1806
|
+
),
|
|
1807
|
+
),
|
|
1808
|
+
const SizedBox(width: 8),
|
|
1809
|
+
Column(
|
|
1810
|
+
crossAxisAlignment: CrossAxisAlignment.end,
|
|
1811
|
+
children: <Widget>[
|
|
1812
|
+
_StatusPill(
|
|
1813
|
+
label: enabled ? provider.statusLabel : 'Disabled',
|
|
1814
|
+
color: enabled ? provider.statusColor : _textSecondary,
|
|
1815
|
+
),
|
|
1816
|
+
const SizedBox(height: 8),
|
|
1817
|
+
InkWell(
|
|
1818
|
+
onTap: hasAdvancedFields || models.isNotEmpty
|
|
1819
|
+
? onExpandToggle
|
|
1820
|
+
: null,
|
|
1821
|
+
borderRadius: BorderRadius.circular(999),
|
|
1822
|
+
child: Container(
|
|
1823
|
+
padding: const EdgeInsets.symmetric(
|
|
1824
|
+
horizontal: 10,
|
|
1825
|
+
vertical: 6,
|
|
1826
|
+
),
|
|
1827
|
+
decoration: BoxDecoration(
|
|
1828
|
+
color: _bgCard,
|
|
1829
|
+
borderRadius: BorderRadius.circular(999),
|
|
1830
|
+
border: Border.all(color: _border),
|
|
1831
|
+
),
|
|
1832
|
+
child: Row(
|
|
1833
|
+
mainAxisSize: MainAxisSize.min,
|
|
1834
|
+
children: <Widget>[
|
|
1835
|
+
Text(
|
|
1836
|
+
expanded ? 'Hide' : 'Setup',
|
|
1837
|
+
style: TextStyle(fontSize: 12),
|
|
1838
|
+
),
|
|
1839
|
+
const SizedBox(width: 4),
|
|
1840
|
+
Icon(
|
|
1841
|
+
expanded
|
|
1842
|
+
? Icons.keyboard_arrow_up
|
|
1843
|
+
: Icons.keyboard_arrow_down,
|
|
1844
|
+
size: 16,
|
|
1845
|
+
color: _textSecondary,
|
|
1846
|
+
),
|
|
1847
|
+
],
|
|
1848
|
+
),
|
|
1849
|
+
),
|
|
1850
|
+
),
|
|
1851
|
+
],
|
|
1852
|
+
),
|
|
1853
|
+
],
|
|
1854
|
+
),
|
|
1855
|
+
const SizedBox(height: 12),
|
|
1856
|
+
Wrap(
|
|
1857
|
+
spacing: 8,
|
|
1858
|
+
runSpacing: 8,
|
|
1859
|
+
children: <Widget>[
|
|
1860
|
+
_MetaPill(
|
|
1861
|
+
label: '$availableCount of ${models.length} models ready',
|
|
1862
|
+
icon: Icons.memory_outlined,
|
|
1863
|
+
),
|
|
1864
|
+
if (provider.supportsApiKey && provider.credentialConfigured)
|
|
1865
|
+
const _MetaPill(
|
|
1866
|
+
label: 'Credentials ready',
|
|
1867
|
+
icon: Icons.lock_outline,
|
|
1868
|
+
),
|
|
1869
|
+
if (provider.supportsApiKey && !provider.credentialConfigured)
|
|
1870
|
+
const _MetaPill(
|
|
1871
|
+
label: 'Credentials needed',
|
|
1872
|
+
icon: Icons.admin_panel_settings_outlined,
|
|
1873
|
+
),
|
|
1874
|
+
if (provider.supportsBaseUrl &&
|
|
1875
|
+
baseUrlController.text.trim().isNotEmpty)
|
|
1876
|
+
_MetaPill(
|
|
1877
|
+
label: _friendlyBaseUrlLabel(baseUrlController.text.trim()),
|
|
1878
|
+
icon: Icons.link_outlined,
|
|
1879
|
+
),
|
|
1880
|
+
],
|
|
1881
|
+
),
|
|
1882
|
+
const SizedBox(height: 12),
|
|
1883
|
+
Container(
|
|
1884
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
1885
|
+
decoration: BoxDecoration(
|
|
1886
|
+
color: _bgCard,
|
|
1887
|
+
borderRadius: BorderRadius.circular(14),
|
|
1888
|
+
border: Border.all(color: _border),
|
|
1889
|
+
),
|
|
1890
|
+
child: Row(
|
|
1891
|
+
children: <Widget>[
|
|
1892
|
+
Expanded(
|
|
1893
|
+
child: Text(
|
|
1894
|
+
provider.availabilityReason,
|
|
1895
|
+
style: TextStyle(color: _textSecondary, height: 1.35),
|
|
1896
|
+
),
|
|
1897
|
+
),
|
|
1898
|
+
const SizedBox(width: 12),
|
|
1899
|
+
Switch(value: enabled, onChanged: onEnabledChanged),
|
|
1900
|
+
],
|
|
1901
|
+
),
|
|
1902
|
+
),
|
|
1903
|
+
if (expanded) ...<Widget>[
|
|
1904
|
+
const SizedBox(height: 14),
|
|
1905
|
+
if (provider.supportsApiKey)
|
|
1906
|
+
Container(
|
|
1907
|
+
width: double.infinity,
|
|
1908
|
+
padding: const EdgeInsets.all(12),
|
|
1909
|
+
margin: const EdgeInsets.only(bottom: 12),
|
|
1910
|
+
decoration: BoxDecoration(
|
|
1911
|
+
color: _bgCard,
|
|
1912
|
+
borderRadius: BorderRadius.circular(14),
|
|
1913
|
+
border: Border.all(color: _border),
|
|
1914
|
+
),
|
|
1915
|
+
child: Text(
|
|
1916
|
+
provider.credentialConfigured
|
|
1917
|
+
? 'Credentials for this provider are already available to the runtime.'
|
|
1918
|
+
: 'Credentials for this provider are managed outside this workspace UI. Finish the server or admin setup, then return here to enable routing.',
|
|
1919
|
+
style: TextStyle(color: _textSecondary, height: 1.35),
|
|
1920
|
+
),
|
|
1921
|
+
),
|
|
1922
|
+
if (provider.supportsBaseUrl) ...<Widget>[
|
|
1923
|
+
TextField(
|
|
1924
|
+
controller: baseUrlController,
|
|
1925
|
+
keyboardType: TextInputType.url,
|
|
1926
|
+
autocorrect: false,
|
|
1927
|
+
decoration: InputDecoration(
|
|
1928
|
+
labelText: provider.id == 'ollama'
|
|
1929
|
+
? 'Server URL'
|
|
1930
|
+
: 'Base URL',
|
|
1931
|
+
helperText: provider.defaultBaseUrl.trim().isEmpty
|
|
1932
|
+
? 'Optional override.'
|
|
1933
|
+
: 'Default: ${provider.defaultBaseUrl}',
|
|
1934
|
+
),
|
|
1935
|
+
),
|
|
1936
|
+
const SizedBox(height: 12),
|
|
1937
|
+
],
|
|
1938
|
+
if (models.isNotEmpty) ...<Widget>[
|
|
1939
|
+
Text('Models', style: TextStyle(fontWeight: FontWeight.w700)),
|
|
1940
|
+
const SizedBox(height: 8),
|
|
1941
|
+
Wrap(
|
|
1942
|
+
spacing: 8,
|
|
1943
|
+
runSpacing: 8,
|
|
1944
|
+
children: models
|
|
1945
|
+
.map(
|
|
1946
|
+
(model) => Container(
|
|
1947
|
+
padding: const EdgeInsets.symmetric(
|
|
1948
|
+
horizontal: 10,
|
|
1949
|
+
vertical: 8,
|
|
1950
|
+
),
|
|
1951
|
+
decoration: BoxDecoration(
|
|
1952
|
+
color: model.available ? _bgCard : _bgPrimary,
|
|
1953
|
+
borderRadius: BorderRadius.circular(999),
|
|
1954
|
+
border: Border.all(
|
|
1955
|
+
color: model.available ? _border : _borderLight,
|
|
1956
|
+
),
|
|
1957
|
+
),
|
|
1958
|
+
child: Text(
|
|
1959
|
+
model.label,
|
|
1960
|
+
style: TextStyle(
|
|
1961
|
+
fontSize: 12,
|
|
1962
|
+
color: model.available
|
|
1963
|
+
? _textPrimary
|
|
1964
|
+
: _textSecondary,
|
|
1965
|
+
),
|
|
1966
|
+
),
|
|
1967
|
+
),
|
|
1968
|
+
)
|
|
1969
|
+
.toList(),
|
|
1970
|
+
),
|
|
1971
|
+
],
|
|
1972
|
+
],
|
|
1973
|
+
],
|
|
1974
|
+
),
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
class _RoutingSelectCard extends StatelessWidget {
|
|
1980
|
+
const _RoutingSelectCard({
|
|
1981
|
+
required this.label,
|
|
1982
|
+
required this.icon,
|
|
1983
|
+
required this.value,
|
|
1984
|
+
required this.items,
|
|
1985
|
+
required this.onChanged,
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
final String label;
|
|
1989
|
+
final IconData icon;
|
|
1990
|
+
final String value;
|
|
1991
|
+
final List<DropdownMenuItem<String>> items;
|
|
1992
|
+
final ValueChanged<String?> onChanged;
|
|
1993
|
+
|
|
1994
|
+
@override
|
|
1995
|
+
Widget build(BuildContext context) {
|
|
1996
|
+
return Container(
|
|
1997
|
+
padding: const EdgeInsets.all(14),
|
|
1998
|
+
decoration: BoxDecoration(
|
|
1999
|
+
color: _bgSecondary,
|
|
2000
|
+
borderRadius: BorderRadius.circular(16),
|
|
2001
|
+
border: Border.all(color: _border),
|
|
2002
|
+
),
|
|
2003
|
+
child: Column(
|
|
2004
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2005
|
+
children: <Widget>[
|
|
2006
|
+
Row(
|
|
2007
|
+
children: <Widget>[
|
|
2008
|
+
Icon(icon, size: 16, color: _accentHover),
|
|
2009
|
+
const SizedBox(width: 8),
|
|
2010
|
+
Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
|
|
2011
|
+
],
|
|
2012
|
+
),
|
|
2013
|
+
const SizedBox(height: 10),
|
|
2014
|
+
DropdownButtonFormField<String>(
|
|
2015
|
+
initialValue: value,
|
|
2016
|
+
items: items,
|
|
2017
|
+
decoration: const InputDecoration(isDense: true),
|
|
2018
|
+
onChanged: onChanged,
|
|
2019
|
+
),
|
|
2020
|
+
],
|
|
2021
|
+
),
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
}
|