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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.env.example +39 -0
  2. package/README.md +2 -0
  3. package/docs/capabilities.md +2 -2
  4. package/docs/configuration.md +13 -5
  5. package/docs/integrations.md +4 -1
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +109 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  30. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  31. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  32. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  33. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  35. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  36. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  37. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  38. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  39. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  43. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  44. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  45. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  46. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  48. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  49. package/flutter_app/android/build.gradle.kts +24 -0
  50. package/flutter_app/android/ci-release.keystore +0 -0
  51. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  52. package/flutter_app/android/gradle.properties +3 -0
  53. package/flutter_app/android/key.properties +4 -0
  54. package/flutter_app/android/settings.gradle.kts +26 -0
  55. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  57. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  58. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  59. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  60. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  62. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  63. package/flutter_app/lib/features/location/location_service.dart +119 -0
  64. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  65. package/flutter_app/lib/main.dart +23057 -0
  66. package/flutter_app/lib/main_app_shell.dart +1682 -0
  67. package/flutter_app/lib/main_integrations.dart +931 -0
  68. package/flutter_app/lib/main_launcher.dart +959 -0
  69. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  70. package/flutter_app/lib/main_models.dart +3473 -0
  71. package/flutter_app/lib/main_shared.dart +2861 -0
  72. package/flutter_app/lib/main_theme.dart +204 -0
  73. package/flutter_app/lib/main_voice_assistant.dart +831 -0
  74. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  75. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  76. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  77. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  78. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  79. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  80. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  81. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  82. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  83. package/flutter_app/lib/src/backend_client.dart +1833 -0
  84. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  85. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  86. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  87. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  88. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  89. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  90. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  91. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  92. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  93. package/flutter_app/lib/src/health_bridge.dart +136 -0
  94. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  95. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  96. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  97. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  98. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  99. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  100. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  101. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  102. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  103. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  104. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  105. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  106. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  107. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  108. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  109. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  110. package/flutter_app/lib/src/theme/palette.dart +81 -0
  111. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  112. package/flutter_app/linux/CMakeLists.txt +128 -0
  113. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  114. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  115. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  116. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  117. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  118. package/flutter_app/linux/runner/main.cc +6 -0
  119. package/flutter_app/linux/runner/my_application.cc +144 -0
  120. package/flutter_app/linux/runner/my_application.h +18 -0
  121. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  122. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  123. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  124. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  125. package/flutter_app/macos/Podfile +42 -0
  126. package/flutter_app/macos/Podfile.lock +87 -0
  127. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  128. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  129. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  130. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  131. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  132. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  133. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  134. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  135. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  136. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  137. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  138. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  139. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  140. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  141. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  142. package/flutter_app/macos/Runner/Info.plist +36 -0
  143. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  144. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  145. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  146. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  147. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  148. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  149. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  150. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  151. package/flutter_app/patch_strings.py +12 -0
  152. package/flutter_app/pubspec.lock +1088 -0
  153. package/flutter_app/pubspec.yaml +53 -0
  154. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  155. package/flutter_app/test/recording_payloads_test.dart +53 -0
  156. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  157. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  158. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  159. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  160. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  161. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  162. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  163. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  164. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  165. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  166. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  167. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  168. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  169. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  170. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  171. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  172. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  173. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  174. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  175. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  176. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  177. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  178. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  179. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  180. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  181. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  182. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  183. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  184. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  185. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  186. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  187. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  188. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  189. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  190. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  191. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  192. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  193. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  194. package/flutter_app/web/favicon.png +0 -0
  195. package/flutter_app/web/favicon.svg +12 -0
  196. package/flutter_app/web/icons/Icon-192.png +0 -0
  197. package/flutter_app/web/icons/Icon-512.png +0 -0
  198. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  199. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  200. package/flutter_app/web/index.html +39 -0
  201. package/flutter_app/web/manifest.json +35 -0
  202. package/flutter_app/windows/CMakeLists.txt +108 -0
  203. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  204. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  205. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  206. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  207. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  208. package/flutter_app/windows/runner/Runner.rc +121 -0
  209. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  210. package/flutter_app/windows/runner/flutter_window.h +37 -0
  211. package/flutter_app/windows/runner/main.cpp +53 -0
  212. package/flutter_app/windows/runner/resource.h +16 -0
  213. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  214. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  215. package/flutter_app/windows/runner/utils.cpp +65 -0
  216. package/flutter_app/windows/runner/utils.h +19 -0
  217. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  218. package/flutter_app/windows/runner/win32_window.h +102 -0
  219. package/lib/manager.js +231 -7
  220. package/package.json +3 -1
  221. package/server/db/database.js +68 -0
  222. package/server/http/middleware.js +50 -0
  223. package/server/http/routes.js +3 -1
  224. package/server/index.js +1 -0
  225. package/server/public/.last_build_id +1 -1
  226. package/server/public/assets/NOTICES +61 -0
  227. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  228. package/server/public/flutter_bootstrap.js +1 -1
  229. package/server/public/main.dart.js +65262 -64422
  230. package/server/routes/integrations.js +86 -0
  231. package/server/routes/memory.js +11 -2
  232. package/server/routes/screenHistory.js +46 -0
  233. package/server/routes/triggers.js +81 -0
  234. package/server/services/ai/models.js +30 -0
  235. package/server/services/ai/providers/githubCopilot.js +97 -0
  236. package/server/services/ai/providers/openai.js +2 -1
  237. package/server/services/ai/providers/openaiCodex.js +31 -0
  238. package/server/services/ai/settings.js +20 -0
  239. package/server/services/ai/systemPrompt.js +1 -1
  240. package/server/services/ai/tools.js +35 -6
  241. package/server/services/browser/controller.js +47 -3
  242. package/server/services/desktop/screenRecorder.js +172 -0
  243. package/server/services/integrations/env.js +5 -0
  244. package/server/services/integrations/github/common.js +106 -0
  245. package/server/services/integrations/github/provider.js +499 -0
  246. package/server/services/integrations/github/repos.js +1124 -0
  247. package/server/services/integrations/home_assistant/provider.js +306 -26
  248. package/server/services/integrations/manager.js +63 -7
  249. package/server/services/integrations/oauth_provider.js +13 -6
  250. package/server/services/integrations/provider_config_store.js +76 -0
  251. package/server/services/integrations/registry.js +4 -0
  252. package/server/services/integrations/trello/provider.js +744 -0
  253. package/server/services/integrations/whatsapp/provider.js +6 -2
  254. package/server/services/manager.js +22 -0
  255. package/server/services/memory/manager.js +39 -2
  256. package/server/services/skills/base_catalog.js +33 -0
  257. package/server/services/tasks/adapters/index.js +1 -0
  258. package/server/services/tasks/adapters/manual.js +12 -0
  259. package/server/services/tasks/runtime.js +1 -1
  260. package/server/services/voice/openaiClient.js +4 -1
  261. package/server/services/voice/providers.js +2 -1
  262. package/server/services/widgets/service.js +49 -4
  263. package/server/utils/local_secrets.js +56 -0
  264. package/server/utils/logger.js +37 -9
@@ -0,0 +1,959 @@
1
+ part of 'main.dart';
2
+
3
+ enum _LauncherPage { assistant, widgets, recordings, settings }
4
+
5
+ class LauncherHomeView extends StatefulWidget {
6
+ const LauncherHomeView({super.key, required this.controller});
7
+
8
+ final NeoAgentController controller;
9
+
10
+ @override
11
+ State<LauncherHomeView> createState() => _LauncherHomeViewState();
12
+ }
13
+
14
+ class _LauncherHomeViewState extends State<LauncherHomeView> {
15
+ static const int _assistantButtonKeyCode = 131;
16
+ static const int _recordingButtonKeyCode = 132;
17
+
18
+ final AndroidLauncherBridge _launcherBridge = AndroidLauncherBridge.instance;
19
+ StreamSubscription<LauncherHardwareButtonEvent>? _buttonSubscription;
20
+
21
+ _LauncherPage _selectedPage = _LauncherPage.assistant;
22
+ LauncherVolumeState? _volumeState;
23
+ LauncherDeviceStatus? _deviceStatus;
24
+ bool _assistantHardwareCaptureActive = false;
25
+ bool _recordingConfirmOpen = false;
26
+ DateTime _now = DateTime.now();
27
+ Timer? _statusTimer;
28
+
29
+ @override
30
+ void initState() {
31
+ super.initState();
32
+ unawaited(_refreshVolumeState(retries: 4));
33
+ unawaited(_refreshDeviceStatus(retries: 4));
34
+ _statusTimer = Timer.periodic(const Duration(seconds: 30), (_) {
35
+ if (!mounted) {
36
+ return;
37
+ }
38
+ setState(() {
39
+ _now = DateTime.now();
40
+ });
41
+ unawaited(_refreshDeviceStatus(retries: 1));
42
+ });
43
+ if (_launcherBridge.supported) {
44
+ _buttonSubscription = _launcherBridge.buttonEvents.listen(
45
+ _handleHardwareButtonEvent,
46
+ );
47
+ }
48
+ }
49
+
50
+ @override
51
+ void dispose() {
52
+ _buttonSubscription?.cancel();
53
+ _statusTimer?.cancel();
54
+ super.dispose();
55
+ }
56
+
57
+ void _showLauncherActionError(String message) {
58
+ if (!mounted) {
59
+ return;
60
+ }
61
+ ScaffoldMessenger.of(
62
+ context,
63
+ ).showSnackBar(SnackBar(content: Text(message)));
64
+ }
65
+
66
+ Future<void> _openWifiSettings() async {
67
+ final opened = await _launcherBridge.openWifiSettings();
68
+ if (!opened) {
69
+ _showLauncherActionError('Unable to open Wi-Fi settings on this build.');
70
+ }
71
+ }
72
+
73
+ Future<void> _openTimeSettings() async {
74
+ final opened = await _launcherBridge.openTimeSettings();
75
+ if (!opened) {
76
+ _showLauncherActionError('Unable to open time settings on this build.');
77
+ }
78
+ }
79
+
80
+ bool get _supportsQrLoginApproval =>
81
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
82
+
83
+ Future<void> _startQrLoginApproval() async {
84
+ final controller = widget.controller;
85
+ if (!controller.isAuthenticated) {
86
+ _showLauncherActionError(
87
+ 'Sign in to this NeoAgent server before scanning a pairing QR code.',
88
+ );
89
+ return;
90
+ }
91
+
92
+ final scanned = await showDialog<String>(
93
+ context: context,
94
+ barrierDismissible: true,
95
+ builder: (dialogContext) => const _QrLoginScannerDialog(),
96
+ );
97
+ if (!mounted || scanned == null || scanned.trim().isEmpty) {
98
+ return;
99
+ }
100
+
101
+ final payload = QrLoginScanPayload.tryParse(scanned);
102
+ if (payload == null) {
103
+ _showLauncherActionError(
104
+ 'That QR code is not a NeoAgent pairing request.',
105
+ );
106
+ return;
107
+ }
108
+
109
+ final scannedBackend = controller._normalizeBackendUrl(payload.backendUrl);
110
+ final currentBackend = controller._normalizeBackendUrl(
111
+ controller.backendUrl,
112
+ );
113
+ if (scannedBackend != currentBackend) {
114
+ _showLauncherActionError(
115
+ 'This code belongs to a different NeoAgent server: ${payload.backendUrl}',
116
+ );
117
+ return;
118
+ }
119
+
120
+ try {
121
+ final preview = await controller.resolveQrLoginApproval(payload);
122
+ if (!mounted) {
123
+ return;
124
+ }
125
+ final approved = await showDialog<bool>(
126
+ context: context,
127
+ builder: (dialogContext) {
128
+ return _QrLoginApprovalDialog(
129
+ preview: preview,
130
+ busy: controller.isApprovingQrLogin,
131
+ );
132
+ },
133
+ );
134
+ if (approved != true || !mounted) {
135
+ return;
136
+ }
137
+ await controller.approveQrLogin(payload);
138
+ if (!mounted) {
139
+ return;
140
+ }
141
+ ScaffoldMessenger.of(context).showSnackBar(
142
+ SnackBar(
143
+ content: Text(
144
+ 'Approved pairing for ${preview.requestedDevice.label}.',
145
+ ),
146
+ ),
147
+ );
148
+ } catch (_) {
149
+ if (!mounted) {
150
+ return;
151
+ }
152
+ _showLauncherActionError(
153
+ controller.errorMessage ?? 'Could not approve QR pairing.',
154
+ );
155
+ }
156
+ }
157
+
158
+ Future<void> _refreshDeviceStatus({int retries = 0}) async {
159
+ final status = await _launcherBridge.fetchDeviceStatus();
160
+ if (!mounted) {
161
+ return;
162
+ }
163
+ if (status == null) {
164
+ if (retries > 0) {
165
+ Future<void>.delayed(const Duration(milliseconds: 500), () {
166
+ if (!mounted) {
167
+ return;
168
+ }
169
+ unawaited(_refreshDeviceStatus(retries: retries - 1));
170
+ });
171
+ }
172
+ return;
173
+ }
174
+ setState(() {
175
+ _deviceStatus = status;
176
+ });
177
+ }
178
+
179
+ Future<void> _refreshVolumeState({int retries = 0}) async {
180
+ final state = await _launcherBridge.fetchVolumeState();
181
+ if (!mounted) {
182
+ return;
183
+ }
184
+ if (state == null) {
185
+ if (retries > 0) {
186
+ Future<void>.delayed(const Duration(milliseconds: 400), () {
187
+ if (!mounted) {
188
+ return;
189
+ }
190
+ unawaited(_refreshVolumeState(retries: retries - 1));
191
+ });
192
+ }
193
+ return;
194
+ }
195
+ setState(() {
196
+ _volumeState = state;
197
+ });
198
+ }
199
+
200
+ Future<void> _updateVolume(double normalized) async {
201
+ final state = _volumeState;
202
+ if (state == null) {
203
+ return;
204
+ }
205
+ final nextValue =
206
+ state.min + ((state.max - state.min) * normalized).round();
207
+ final updated = await _launcherBridge.setVolume(nextValue);
208
+ if (!mounted || updated == null) {
209
+ return;
210
+ }
211
+ setState(() {
212
+ _volumeState = updated;
213
+ });
214
+ }
215
+
216
+ Future<void> _adjustVolume(int delta) async {
217
+ final updated = await _launcherBridge.adjustVolume(delta);
218
+ if (!mounted || updated == null) {
219
+ return;
220
+ }
221
+ setState(() {
222
+ _volumeState = updated;
223
+ });
224
+ }
225
+
226
+ Future<void> _toggleRecordingFromHardware() async {
227
+ final controller = widget.controller;
228
+ if (controller.recordingRuntime.active) {
229
+ await controller.stopRecording(stopReason: 'hardware_button');
230
+ return;
231
+ }
232
+ if (!await _confirmRecordingStart(sourceLabel: 'hardware button')) {
233
+ return;
234
+ }
235
+ await controller.startBackgroundRecording();
236
+ }
237
+
238
+ Future<void> _startRecordingFromUi() async {
239
+ final controller = widget.controller;
240
+ if (controller.recordingRuntime.active || controller.isStartingRecording) {
241
+ return;
242
+ }
243
+ if (!await _confirmRecordingStart(sourceLabel: 'Start button')) {
244
+ return;
245
+ }
246
+ await controller.startBackgroundRecording();
247
+ }
248
+
249
+ Future<bool> _confirmRecordingStart({required String sourceLabel}) async {
250
+ if (!mounted || _recordingConfirmOpen) {
251
+ return false;
252
+ }
253
+ _recordingConfirmOpen = true;
254
+ final confirmed = await showDialog<bool>(
255
+ context: context,
256
+ builder: (context) {
257
+ return AlertDialog(
258
+ title: const Text('Start recording?'),
259
+ content: Text(
260
+ 'Recording was requested from $sourceLabel. Start a new background recording session now?',
261
+ ),
262
+ actions: <Widget>[
263
+ TextButton(
264
+ onPressed: () => Navigator.of(context).pop(false),
265
+ child: const Text('Cancel'),
266
+ ),
267
+ FilledButton(
268
+ onPressed: () => Navigator.of(context).pop(true),
269
+ child: const Text('Start'),
270
+ ),
271
+ ],
272
+ );
273
+ },
274
+ );
275
+ _recordingConfirmOpen = false;
276
+ return confirmed == true;
277
+ }
278
+
279
+ Future<void> _startAssistantFromHardware() async {
280
+ if (_assistantHardwareCaptureActive) {
281
+ return;
282
+ }
283
+ _assistantHardwareCaptureActive = true;
284
+ try {
285
+ await widget.controller.startLiveVoiceCapture();
286
+ } catch (_) {
287
+ _assistantHardwareCaptureActive = false;
288
+ }
289
+ }
290
+
291
+ Future<void> _stopAssistantFromHardware() async {
292
+ if (!_assistantHardwareCaptureActive &&
293
+ !widget.controller.isLiveVoiceCaptureActive &&
294
+ !widget.controller.isLiveVoiceCaptureStarting) {
295
+ return;
296
+ }
297
+ _assistantHardwareCaptureActive = false;
298
+ await widget.controller.stopLiveVoiceCapture();
299
+ }
300
+
301
+ void _handleHardwareButtonEvent(LauncherHardwareButtonEvent event) {
302
+ if (!mounted) {
303
+ return;
304
+ }
305
+ if (event.keyCode == _assistantButtonKeyCode) {
306
+ if (event.isDown && event.repeatCount == 0) {
307
+ unawaited(_startAssistantFromHardware());
308
+ } else if (event.isUp) {
309
+ unawaited(_stopAssistantFromHardware());
310
+ }
311
+ return;
312
+ }
313
+
314
+ if (event.keyCode == _recordingButtonKeyCode && event.isUp) {
315
+ unawaited(_toggleRecordingFromHardware());
316
+ }
317
+ }
318
+
319
+ Widget _buildAssistantPage() {
320
+ return VoiceAssistantPanel(controller: widget.controller);
321
+ }
322
+
323
+ Widget _buildRecordingsPage() {
324
+ final controller = widget.controller;
325
+ final runtime = controller.recordingRuntime;
326
+ final recentSessions = controller.recordingSessions.take(4).toList();
327
+ return ListView(
328
+ padding: _pagePadding(context),
329
+ children: <Widget>[
330
+ _PageTitle(
331
+ title: 'Recorder',
332
+ subtitle:
333
+ 'Record audio and keep it running while you use the device.',
334
+ trailing: Wrap(
335
+ spacing: 10,
336
+ runSpacing: 10,
337
+ children: <Widget>[
338
+ FilledButton.icon(
339
+ onPressed: controller.isStartingRecording || runtime.active
340
+ ? null
341
+ : _startRecordingFromUi,
342
+ icon: const Icon(Icons.mic_none_outlined),
343
+ label: const Text('Start'),
344
+ ),
345
+ OutlinedButton.icon(
346
+ onPressed: runtime.active
347
+ ? (runtime.paused
348
+ ? controller.resumeBackgroundRecording
349
+ : controller.pauseBackgroundRecording)
350
+ : null,
351
+ icon: Icon(
352
+ runtime.paused
353
+ ? Icons.play_circle_outline
354
+ : Icons.pause_circle_outline,
355
+ ),
356
+ label: Text(runtime.paused ? 'Resume' : 'Pause'),
357
+ ),
358
+ OutlinedButton.icon(
359
+ onPressed: runtime.active && !controller.isStoppingRecording
360
+ ? controller.stopRecording
361
+ : null,
362
+ icon: const Icon(Icons.stop_circle_outlined),
363
+ label: const Text('Stop'),
364
+ ),
365
+ ],
366
+ ),
367
+ ),
368
+ Card(
369
+ child: Padding(
370
+ padding: const EdgeInsets.all(18),
371
+ child: Column(
372
+ crossAxisAlignment: CrossAxisAlignment.start,
373
+ children: <Widget>[
374
+ Wrap(
375
+ spacing: 10,
376
+ runSpacing: 10,
377
+ children: <Widget>[
378
+ _DotStatus(
379
+ label: runtime.active
380
+ ? (runtime.paused ? 'Paused' : 'Recording')
381
+ : 'Idle',
382
+ color: runtime.active
383
+ ? (runtime.paused ? _warning : _danger)
384
+ : _success,
385
+ ),
386
+ _MetaPill(
387
+ icon: Icons.mic_outlined,
388
+ label: runtime.supportsBackgroundMic
389
+ ? 'Background recording ready'
390
+ : 'Recording unavailable',
391
+ ),
392
+ ],
393
+ ),
394
+ if (runtime.errorMessage != null &&
395
+ runtime.errorMessage!.trim().isNotEmpty) ...<Widget>[
396
+ const SizedBox(height: 14),
397
+ _InlineError(message: runtime.errorMessage!),
398
+ ],
399
+ ],
400
+ ),
401
+ ),
402
+ ),
403
+ const SizedBox(height: 16),
404
+ Card(
405
+ child: Padding(
406
+ padding: const EdgeInsets.all(18),
407
+ child: Column(
408
+ crossAxisAlignment: CrossAxisAlignment.start,
409
+ children: <Widget>[
410
+ const _SectionTitle('Recent Sessions'),
411
+ const SizedBox(height: 10),
412
+ if (recentSessions.isEmpty)
413
+ Text(
414
+ 'No recordings yet.',
415
+ style: TextStyle(color: _textSecondary),
416
+ )
417
+ else
418
+ ...recentSessions.map(
419
+ (session) => Padding(
420
+ padding: const EdgeInsets.only(bottom: 12),
421
+ child: Row(
422
+ crossAxisAlignment: CrossAxisAlignment.start,
423
+ children: <Widget>[
424
+ Icon(Icons.audio_file_outlined, color: _accent),
425
+ const SizedBox(width: 10),
426
+ Expanded(
427
+ child: Column(
428
+ crossAxisAlignment: CrossAxisAlignment.start,
429
+ children: <Widget>[
430
+ Text(
431
+ session.title,
432
+ style: const TextStyle(
433
+ fontWeight: FontWeight.w700,
434
+ ),
435
+ ),
436
+ const SizedBox(height: 4),
437
+ Text(
438
+ '${session.statusLabel} • ${session.startedAtLabel}',
439
+ style: TextStyle(color: _textSecondary),
440
+ ),
441
+ ],
442
+ ),
443
+ ),
444
+ ],
445
+ ),
446
+ ),
447
+ ),
448
+ Align(
449
+ alignment: Alignment.centerLeft,
450
+ child: OutlinedButton.icon(
451
+ onPressed: controller.refreshRecordings,
452
+ icon: const Icon(Icons.refresh),
453
+ label: const Text('Refresh'),
454
+ ),
455
+ ),
456
+ ],
457
+ ),
458
+ ),
459
+ ),
460
+ ],
461
+ );
462
+ }
463
+
464
+ Widget _buildWidgetsPage() {
465
+ final controller = widget.controller;
466
+ return ListView(
467
+ padding: _pagePadding(context),
468
+ children: <Widget>[
469
+ _PageTitle(
470
+ title: 'Widgets',
471
+ subtitle:
472
+ 'Pinned AI widgets refreshed by the server and rendered here at a glance.',
473
+ ),
474
+ if (controller.widgets.isEmpty)
475
+ const _EmptyCard(
476
+ title: 'No widgets yet',
477
+ subtitle: 'Create one in chat and it will show up here.',
478
+ )
479
+ else
480
+ ...controller.widgets.map(
481
+ (item) => Padding(
482
+ padding: const EdgeInsets.only(bottom: 12),
483
+ child: _AiWidgetCard(
484
+ item: item,
485
+ controller: controller,
486
+ compact: true,
487
+ active: controller.selectedWidgetId == item.id,
488
+ onSelect: () => controller.selectWidget(item.id),
489
+ ),
490
+ ),
491
+ ),
492
+ ],
493
+ );
494
+ }
495
+
496
+ Widget _buildSettingsPage() {
497
+ final controller = widget.controller;
498
+ final volumeState = _volumeState;
499
+ return ListView(
500
+ padding: _pagePadding(context),
501
+ children: <Widget>[
502
+ _PageTitle(
503
+ title: 'Device Settings',
504
+ subtitle:
505
+ 'Adjust speaker volume, review hardware button defaults, and manage this launcher session.',
506
+ ),
507
+ if (_supportsQrLoginApproval) ...<Widget>[
508
+ Card(
509
+ child: Padding(
510
+ padding: const EdgeInsets.all(18),
511
+ child: Column(
512
+ crossAxisAlignment: CrossAxisAlignment.start,
513
+ children: <Widget>[
514
+ Row(
515
+ children: <Widget>[
516
+ const Expanded(child: _SectionTitle('QR Pairing')),
517
+ _StatusPill(label: 'Android only', color: _accent),
518
+ ],
519
+ ),
520
+ const SizedBox(height: 10),
521
+ Text(
522
+ 'Scan a NeoAgent pairing QR from another device and approve it from this launcher session.',
523
+ style: TextStyle(color: _textSecondary, height: 1.45),
524
+ ),
525
+ const SizedBox(height: 16),
526
+ FilledButton.icon(
527
+ onPressed: controller.isApprovingQrLogin
528
+ ? null
529
+ : _startQrLoginApproval,
530
+ icon: controller.isApprovingQrLogin
531
+ ? const SizedBox.square(
532
+ dimension: 16,
533
+ child: CircularProgressIndicator(
534
+ strokeWidth: 2,
535
+ color: Colors.white,
536
+ ),
537
+ )
538
+ : const Icon(Icons.qr_code_scanner_outlined),
539
+ label: Text(
540
+ controller.isApprovingQrLogin
541
+ ? 'Opening scanner...'
542
+ : 'Scan pairing QR',
543
+ ),
544
+ ),
545
+ if (!controller.isAuthenticated) ...<Widget>[
546
+ const SizedBox(height: 10),
547
+ Text(
548
+ 'This requires an authenticated session on the same NeoAgent server.',
549
+ style: TextStyle(color: _textMuted, height: 1.4),
550
+ ),
551
+ ],
552
+ ],
553
+ ),
554
+ ),
555
+ ),
556
+ const SizedBox(height: 16),
557
+ ],
558
+ Card(
559
+ child: Padding(
560
+ padding: const EdgeInsets.all(18),
561
+ child: Column(
562
+ crossAxisAlignment: CrossAxisAlignment.start,
563
+ children: <Widget>[
564
+ const _SectionTitle('Volume'),
565
+ const SizedBox(height: 10),
566
+ Text(
567
+ 'Adjust the speaker volume here.',
568
+ style: TextStyle(color: _textSecondary, height: 1.45),
569
+ ),
570
+ const SizedBox(height: 16),
571
+ Row(
572
+ children: <Widget>[
573
+ IconButton(
574
+ onPressed: _launcherBridge.supported
575
+ ? () => _adjustVolume(-1)
576
+ : null,
577
+ visualDensity: VisualDensity.compact,
578
+ constraints: const BoxConstraints.tightFor(
579
+ width: 40,
580
+ height: 40,
581
+ ),
582
+ padding: EdgeInsets.zero,
583
+ icon: const Icon(Icons.remove_circle_outline),
584
+ ),
585
+ Expanded(
586
+ child: Slider(
587
+ value: volumeState?.normalized ?? 0,
588
+ onChanged: volumeState == null ? null : _updateVolume,
589
+ ),
590
+ ),
591
+ IconButton(
592
+ onPressed: _launcherBridge.supported
593
+ ? () => _adjustVolume(1)
594
+ : null,
595
+ visualDensity: VisualDensity.compact,
596
+ constraints: const BoxConstraints.tightFor(
597
+ width: 40,
598
+ height: 40,
599
+ ),
600
+ padding: EdgeInsets.zero,
601
+ icon: const Icon(Icons.add_circle_outline),
602
+ ),
603
+ ],
604
+ ),
605
+ if (volumeState != null)
606
+ Text(
607
+ 'Level ${volumeState.current}/${volumeState.max}${volumeState.muted ? ' • muted' : ''}',
608
+ style: TextStyle(color: _textSecondary),
609
+ ),
610
+ const SizedBox(height: 14),
611
+ OutlinedButton.icon(
612
+ onPressed: _launcherBridge.supported
613
+ ? _openWifiSettings
614
+ : null,
615
+ icon: const Icon(Icons.wifi_outlined),
616
+ label: const Text('Open Wi-Fi settings'),
617
+ ),
618
+ const SizedBox(height: 10),
619
+ OutlinedButton.icon(
620
+ onPressed: _launcherBridge.supported
621
+ ? _openTimeSettings
622
+ : null,
623
+ icon: const Icon(Icons.schedule_outlined),
624
+ label: const Text('Open time settings'),
625
+ ),
626
+ ],
627
+ ),
628
+ ),
629
+ ),
630
+ const SizedBox(height: 16),
631
+ Card(
632
+ child: Padding(
633
+ padding: const EdgeInsets.all(18),
634
+ child: Column(
635
+ crossAxisAlignment: CrossAxisAlignment.start,
636
+ children: <Widget>[
637
+ const _SectionTitle('Extra Buttons'),
638
+ const SizedBox(height: 10),
639
+ Text(
640
+ 'Hold button 131 for the assistant. Press button 132 to start or stop recording.',
641
+ style: TextStyle(color: _textSecondary, height: 1.45),
642
+ ),
643
+ const SizedBox(height: 16),
644
+ Wrap(
645
+ spacing: 10,
646
+ runSpacing: 10,
647
+ children: <Widget>[
648
+ const _MetaPill(
649
+ icon: Icons.keyboard_voice_outlined,
650
+ label: 'Assistant key 131',
651
+ ),
652
+ const _MetaPill(
653
+ icon: Icons.fiber_smart_record_outlined,
654
+ label: 'Recording key 132',
655
+ ),
656
+ ],
657
+ ),
658
+ ],
659
+ ),
660
+ ),
661
+ ),
662
+ const SizedBox(height: 16),
663
+ Card(
664
+ child: Padding(
665
+ padding: const EdgeInsets.all(18),
666
+ child: Column(
667
+ crossAxisAlignment: CrossAxisAlignment.start,
668
+ children: <Widget>[
669
+ const _SectionTitle('App Updates'),
670
+ const SizedBox(height: 10),
671
+ Text(
672
+ 'Keep this launcher up to date.',
673
+ style: TextStyle(color: _textSecondary, height: 1.45),
674
+ ),
675
+ const SizedBox(height: 16),
676
+ if (!controller.appUpdaterConfigured)
677
+ Text(
678
+ 'Updates are not configured for this build.',
679
+ style: TextStyle(color: _textSecondary),
680
+ )
681
+ else ...<Widget>[
682
+ DropdownButtonFormField<String>(
683
+ initialValue: controller.appUpdateChannel,
684
+ decoration: const InputDecoration(
685
+ labelText: 'Release channel',
686
+ ),
687
+ items: const <DropdownMenuItem<String>>[
688
+ DropdownMenuItem<String>(
689
+ value: 'stable',
690
+ child: Text('Stable'),
691
+ ),
692
+ DropdownMenuItem<String>(
693
+ value: 'beta',
694
+ child: Text('Beta'),
695
+ ),
696
+ ],
697
+ onChanged: (value) {
698
+ if (value != null) {
699
+ unawaited(controller.setAppUpdateChannel(value));
700
+ }
701
+ },
702
+ ),
703
+ const SizedBox(height: 8),
704
+ SwitchListTile.adaptive(
705
+ value: controller.appUpdateAutoCheckEnabled,
706
+ contentPadding: EdgeInsets.zero,
707
+ title: const Text('Check automatically on launch'),
708
+ onChanged: controller.setAppUpdateAutoCheckEnabled,
709
+ ),
710
+ Text(
711
+ 'Installed: ${controller.installedAppVersion ?? 'Unknown'} • Last checked: ${controller.appUpdateLastCheckedLabel}',
712
+ style: TextStyle(color: _textSecondary, height: 1.4),
713
+ ),
714
+ const SizedBox(height: 14),
715
+ Wrap(
716
+ spacing: 10,
717
+ runSpacing: 10,
718
+ children: <Widget>[
719
+ FilledButton.icon(
720
+ onPressed: controller.isCheckingAppUpdate
721
+ ? null
722
+ : () => controller.checkForAppUpdates(),
723
+ icon: controller.isCheckingAppUpdate
724
+ ? const SizedBox.square(
725
+ dimension: 16,
726
+ child: CircularProgressIndicator(
727
+ strokeWidth: 2,
728
+ color: Colors.white,
729
+ ),
730
+ )
731
+ : const Icon(Icons.sync),
732
+ label: Text(
733
+ controller.isCheckingAppUpdate
734
+ ? 'Checking...'
735
+ : 'Check now',
736
+ ),
737
+ ),
738
+ OutlinedButton.icon(
739
+ onPressed:
740
+ controller.availableAppUpdate == null ||
741
+ controller.isOpeningAppUpdate
742
+ ? null
743
+ : controller.openAppUpdate,
744
+ icon: controller.isOpeningAppUpdate
745
+ ? const SizedBox.square(
746
+ dimension: 16,
747
+ child: CircularProgressIndicator(
748
+ strokeWidth: 2,
749
+ ),
750
+ )
751
+ : const Icon(Icons.system_update_alt),
752
+ label: Text(
753
+ controller.isOpeningAppUpdate
754
+ ? 'Opening...'
755
+ : 'Download update',
756
+ ),
757
+ ),
758
+ ],
759
+ ),
760
+ if (controller.appUpdateErrorMessage
761
+ case final message?) ...<Widget>[
762
+ const SizedBox(height: 14),
763
+ _InlineError(message: message),
764
+ ],
765
+ if (controller.availableAppUpdate
766
+ case final release?) ...<Widget>[
767
+ const SizedBox(height: 14),
768
+ Container(
769
+ width: double.infinity,
770
+ padding: const EdgeInsets.all(14),
771
+ decoration: BoxDecoration(
772
+ color: _bgSecondary,
773
+ borderRadius: BorderRadius.circular(16),
774
+ border: Border.all(color: _border),
775
+ ),
776
+ child: Column(
777
+ crossAxisAlignment: CrossAxisAlignment.start,
778
+ children: <Widget>[
779
+ Wrap(
780
+ spacing: 10,
781
+ runSpacing: 10,
782
+ children: <Widget>[
783
+ _StatusPill(
784
+ label: 'Update ${release.version}',
785
+ color: release.channel == 'beta'
786
+ ? _warning
787
+ : _accent,
788
+ ),
789
+ _StatusPill(
790
+ label: release.asset.sizeLabel,
791
+ color: _textSecondary,
792
+ ),
793
+ ],
794
+ ),
795
+ const SizedBox(height: 10),
796
+ Text(
797
+ release.title,
798
+ style: const TextStyle(fontWeight: FontWeight.w700),
799
+ ),
800
+ const SizedBox(height: 4),
801
+ Text(
802
+ release.publishedLabel,
803
+ style: TextStyle(color: _textSecondary),
804
+ ),
805
+ ],
806
+ ),
807
+ ),
808
+ ],
809
+ ],
810
+ ],
811
+ ),
812
+ ),
813
+ ),
814
+ const SizedBox(height: 16),
815
+ Card(
816
+ child: Padding(
817
+ padding: const EdgeInsets.all(18),
818
+ child: Column(
819
+ crossAxisAlignment: CrossAxisAlignment.start,
820
+ children: <Widget>[
821
+ const _SectionTitle('Session'),
822
+ const SizedBox(height: 10),
823
+ Text(
824
+ controller.backendUrl,
825
+ style: GoogleFonts.jetBrainsMono(
826
+ fontSize: 12,
827
+ color: _textSecondary,
828
+ ),
829
+ ),
830
+ const SizedBox(height: 16),
831
+ OutlinedButton.icon(
832
+ onPressed: controller.logout,
833
+ icon: const Icon(Icons.logout),
834
+ label: const Text('Sign out'),
835
+ ),
836
+ ],
837
+ ),
838
+ ),
839
+ ),
840
+ ],
841
+ );
842
+ }
843
+
844
+ @override
845
+ Widget build(BuildContext context) {
846
+ final controller = widget.controller;
847
+ final compactNav = MediaQuery.sizeOf(context).width < 340;
848
+ final localizations = MaterialLocalizations.of(context);
849
+ final timeLabel = localizations.formatTimeOfDay(
850
+ TimeOfDay.fromDateTime(_now),
851
+ alwaysUse24HourFormat: true,
852
+ );
853
+ final batteryPercent = _deviceStatus?.batteryPercent;
854
+ final batteryLabel = batteryPercent == null ? '--%' : '$batteryPercent%';
855
+ final batteryIcon = _deviceStatus?.charging == true
856
+ ? Icons.battery_charging_full
857
+ : Icons.battery_full;
858
+ return _AmbientBackdrop(
859
+ child: Scaffold(
860
+ backgroundColor: Colors.transparent,
861
+ body: SafeArea(
862
+ child: Column(
863
+ children: <Widget>[
864
+ Padding(
865
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 6),
866
+ child: DecoratedBox(
867
+ decoration: BoxDecoration(
868
+ color: _bgCard.withValues(alpha: 0.9),
869
+ borderRadius: BorderRadius.circular(14),
870
+ border: Border.all(color: _border),
871
+ ),
872
+ child: Padding(
873
+ padding: const EdgeInsets.symmetric(
874
+ horizontal: 12,
875
+ vertical: 8,
876
+ ),
877
+ child: Row(
878
+ children: <Widget>[
879
+ Icon(batteryIcon, size: 18, color: _textSecondary),
880
+ const SizedBox(width: 6),
881
+ Text(
882
+ batteryLabel,
883
+ style: TextStyle(
884
+ color: _textPrimary,
885
+ fontWeight: FontWeight.w600,
886
+ ),
887
+ ),
888
+ const Spacer(),
889
+ Icon(
890
+ Icons.access_time,
891
+ size: 16,
892
+ color: _textSecondary,
893
+ ),
894
+ const SizedBox(width: 6),
895
+ Text(
896
+ timeLabel,
897
+ style: TextStyle(
898
+ color: _textPrimary,
899
+ fontWeight: FontWeight.w600,
900
+ ),
901
+ ),
902
+ ],
903
+ ),
904
+ ),
905
+ ),
906
+ ),
907
+ Expanded(
908
+ child: AnimatedBuilder(
909
+ animation: controller,
910
+ builder: (context, _) {
911
+ final pages = <Widget>[
912
+ _buildAssistantPage(),
913
+ _buildWidgetsPage(),
914
+ _buildRecordingsPage(),
915
+ _buildSettingsPage(),
916
+ ];
917
+ return IndexedStack(
918
+ index: _selectedPage.index,
919
+ children: pages,
920
+ );
921
+ },
922
+ ),
923
+ ),
924
+ ],
925
+ ),
926
+ ),
927
+ bottomNavigationBar: NavigationBar(
928
+ selectedIndex: _selectedPage.index,
929
+ labelBehavior: compactNav
930
+ ? NavigationDestinationLabelBehavior.onlyShowSelected
931
+ : NavigationDestinationLabelBehavior.alwaysShow,
932
+ destinations: const <NavigationDestination>[
933
+ NavigationDestination(
934
+ icon: Icon(Icons.keyboard_voice_outlined),
935
+ label: 'Assistant',
936
+ ),
937
+ NavigationDestination(
938
+ icon: Icon(Icons.dashboard_customize_outlined),
939
+ label: 'Widgets',
940
+ ),
941
+ NavigationDestination(
942
+ icon: Icon(Icons.fiber_smart_record_outlined),
943
+ label: 'Record',
944
+ ),
945
+ NavigationDestination(
946
+ icon: Icon(Icons.tune_outlined),
947
+ label: 'Settings',
948
+ ),
949
+ ],
950
+ onDestinationSelected: (index) {
951
+ setState(() {
952
+ _selectedPage = _LauncherPage.values[index];
953
+ });
954
+ },
955
+ ),
956
+ ),
957
+ );
958
+ }
959
+ }