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,1682 @@
1
+ part of 'main.dart';
2
+
3
+ class SplashView extends StatelessWidget {
4
+ const SplashView({super.key});
5
+
6
+ @override
7
+ Widget build(BuildContext context) {
8
+ return DecoratedBox(
9
+ decoration: BoxDecoration(
10
+ gradient: RadialGradient(
11
+ center: Alignment(-0.4, -0.6),
12
+ radius: 1.3,
13
+ colors: <Color>[_accent, _bgSecondary, _bgPrimary],
14
+ ),
15
+ ),
16
+ child: const Scaffold(
17
+ backgroundColor: Colors.transparent,
18
+ body: Center(
19
+ child: Column(
20
+ mainAxisSize: MainAxisSize.min,
21
+ children: <Widget>[
22
+ _BrandLockup(logoSize: 52),
23
+ SizedBox(height: 18),
24
+ CircularProgressIndicator(),
25
+ SizedBox(height: 16),
26
+ Text('Loading NeoOS'),
27
+ ],
28
+ ),
29
+ ),
30
+ ),
31
+ );
32
+ }
33
+ }
34
+
35
+ class BackendSetupView extends StatefulWidget {
36
+ const BackendSetupView({super.key, required this.controller});
37
+
38
+ final NeoAgentController controller;
39
+
40
+ @override
41
+ State<BackendSetupView> createState() => _BackendSetupViewState();
42
+ }
43
+
44
+ class _BackendSetupViewState extends State<BackendSetupView> {
45
+ late final TextEditingController _backendUrlController;
46
+
47
+ @override
48
+ void initState() {
49
+ super.initState();
50
+ _backendUrlController = TextEditingController(
51
+ text: widget.controller.backendUrl,
52
+ );
53
+ }
54
+
55
+ @override
56
+ void dispose() {
57
+ _backendUrlController.dispose();
58
+ super.dispose();
59
+ }
60
+
61
+ Future<void> _submit() async {
62
+ await widget.controller.saveBackendUrl(_backendUrlController.text);
63
+ }
64
+
65
+ @override
66
+ Widget build(BuildContext context) {
67
+ final controller = widget.controller;
68
+ return _AmbientBackdrop(
69
+ child: Scaffold(
70
+ backgroundColor: Colors.transparent,
71
+ body: SafeArea(
72
+ child: Center(
73
+ child: SingleChildScrollView(
74
+ padding: const EdgeInsets.all(24),
75
+ child: ConstrainedBox(
76
+ constraints: const BoxConstraints(maxWidth: 560),
77
+ child: Container(
78
+ decoration: BoxDecoration(
79
+ gradient: _panelGradient,
80
+ borderRadius: BorderRadius.circular(34),
81
+ border: Border.all(color: _borderLight),
82
+ boxShadow: _softPanelShadow,
83
+ ),
84
+ child: Padding(
85
+ padding: const EdgeInsets.fromLTRB(34, 32, 34, 30),
86
+ child: Column(
87
+ crossAxisAlignment: CrossAxisAlignment.start,
88
+ children: <Widget>[
89
+ const _BrandLockup(logoSize: 60),
90
+ const SizedBox(height: 22),
91
+ Text('FIRST-RUN SETUP', style: _sectionEyebrowStyle()),
92
+ const SizedBox(height: 10),
93
+ Text(
94
+ 'Connect this build to your NeoAgent backend',
95
+ style: _displayTitleStyle(34),
96
+ ),
97
+ const SizedBox(height: 12),
98
+ Text(
99
+ 'This build was not bundled with a backend endpoint. Enter your NeoAgent server URL once and the app will store it locally for future launches.',
100
+ style: TextStyle(color: _textSecondary, height: 1.55),
101
+ ),
102
+ const SizedBox(height: 24),
103
+ TextField(
104
+ controller: _backendUrlController,
105
+ keyboardType: TextInputType.url,
106
+ textInputAction: TextInputAction.done,
107
+ onSubmitted: (_) => _submit(),
108
+ decoration: const InputDecoration(
109
+ labelText: 'Backend URL',
110
+ hintText: 'https://neoagent.example.com',
111
+ prefixIcon: Icon(Icons.cloud_outlined),
112
+ ),
113
+ ),
114
+ const SizedBox(height: 14),
115
+ Container(
116
+ width: double.infinity,
117
+ padding: const EdgeInsets.all(14),
118
+ decoration: BoxDecoration(
119
+ color: _bgSecondary.withValues(alpha: 0.72),
120
+ borderRadius: BorderRadius.circular(18),
121
+ border: Border.all(color: _borderLight),
122
+ ),
123
+ child: Row(
124
+ crossAxisAlignment: CrossAxisAlignment.start,
125
+ children: <Widget>[
126
+ Icon(
127
+ Icons.privacy_tip_outlined,
128
+ color: _accent,
129
+ size: 18,
130
+ ),
131
+ const SizedBox(width: 10),
132
+ Expanded(
133
+ child: Text(
134
+ 'Use your hosted NeoAgent URL. If you enter a hostname without a scheme, the app will infer `https://` for remote hosts and `http://` for local addresses.',
135
+ style: TextStyle(
136
+ color: _textSecondary,
137
+ height: 1.45,
138
+ ),
139
+ ),
140
+ ),
141
+ ],
142
+ ),
143
+ ),
144
+ if (controller.errorMessage
145
+ case final message?) ...<Widget>[
146
+ const SizedBox(height: 16),
147
+ _InlineError(message: message),
148
+ ],
149
+ const SizedBox(height: 22),
150
+ SizedBox(
151
+ width: double.infinity,
152
+ child: FilledButton.icon(
153
+ onPressed: controller.isSavingBackendUrl
154
+ ? null
155
+ : _submit,
156
+ style: FilledButton.styleFrom(
157
+ backgroundColor: _accent,
158
+ padding: const EdgeInsets.symmetric(vertical: 16),
159
+ ),
160
+ icon: controller.isSavingBackendUrl
161
+ ? const SizedBox.square(
162
+ dimension: 18,
163
+ child: CircularProgressIndicator(
164
+ strokeWidth: 2,
165
+ color: Colors.white,
166
+ ),
167
+ )
168
+ : const Icon(Icons.arrow_forward_rounded),
169
+ label: Text(
170
+ controller.isSavingBackendUrl
171
+ ? 'Connecting...'
172
+ : 'Connect Backend',
173
+ ),
174
+ ),
175
+ ),
176
+ ],
177
+ ),
178
+ ),
179
+ ),
180
+ ),
181
+ ),
182
+ ),
183
+ ),
184
+ ),
185
+ );
186
+ }
187
+ }
188
+
189
+ class AuthView extends StatefulWidget {
190
+ const AuthView({super.key, required this.controller});
191
+
192
+ final NeoAgentController controller;
193
+
194
+ @override
195
+ State<AuthView> createState() => _AuthViewState();
196
+ }
197
+
198
+ class _AuthViewState extends State<AuthView> {
199
+ late final TextEditingController _usernameController;
200
+ late final TextEditingController _emailController;
201
+ late final TextEditingController _passwordController;
202
+ late final TextEditingController _confirmPasswordController;
203
+ late final TextEditingController _twoFactorController;
204
+ bool _registerMode = false;
205
+ bool _qrAutoRequestedForVisibleMode = false;
206
+
207
+ @override
208
+ void initState() {
209
+ super.initState();
210
+ _usernameController = TextEditingController(
211
+ text: widget.controller.username,
212
+ );
213
+ _emailController = TextEditingController(
214
+ text: widget.controller.user?['email']?.toString() ?? '',
215
+ );
216
+ _passwordController = TextEditingController(
217
+ text: widget.controller.password,
218
+ );
219
+ _confirmPasswordController = TextEditingController();
220
+ _twoFactorController = TextEditingController();
221
+ }
222
+
223
+ @override
224
+ void dispose() {
225
+ _usernameController.dispose();
226
+ _emailController.dispose();
227
+ _passwordController.dispose();
228
+ _confirmPasswordController.dispose();
229
+ _twoFactorController.dispose();
230
+ super.dispose();
231
+ }
232
+
233
+ Future<void> _showForgotPasswordDialog() async {
234
+ final accountController = TextEditingController(
235
+ text: _usernameController.text.trim(),
236
+ );
237
+ String? inlineError;
238
+ try {
239
+ await showDialog<void>(
240
+ context: context,
241
+ builder: (dialogContext) {
242
+ return StatefulBuilder(
243
+ builder: (context, setDialogState) {
244
+ return AlertDialog(
245
+ backgroundColor: _bgCard,
246
+ title: Text('Reset password'),
247
+ content: SizedBox(
248
+ width: 420,
249
+ child: Column(
250
+ mainAxisSize: MainAxisSize.min,
251
+ crossAxisAlignment: CrossAxisAlignment.stretch,
252
+ children: <Widget>[
253
+ Text(
254
+ 'Enter your username or account email. NeoOS will send a reset link if it can match the account.',
255
+ style: TextStyle(color: _textSecondary, height: 1.45),
256
+ ),
257
+ const SizedBox(height: 16),
258
+ TextField(
259
+ controller: accountController,
260
+ keyboardType: TextInputType.emailAddress,
261
+ decoration: const InputDecoration(
262
+ labelText: 'Username or email',
263
+ ),
264
+ ),
265
+ if (inlineError != null) ...<Widget>[
266
+ const SizedBox(height: 12),
267
+ _InlineError(message: inlineError!),
268
+ ],
269
+ ],
270
+ ),
271
+ ),
272
+ actions: <Widget>[
273
+ TextButton(
274
+ onPressed: widget.controller.isAuthenticating
275
+ ? null
276
+ : () => Navigator.of(dialogContext).pop(),
277
+ child: Text('Cancel'),
278
+ ),
279
+ FilledButton(
280
+ onPressed: widget.controller.isAuthenticating
281
+ ? null
282
+ : () async {
283
+ final account = accountController.text.trim();
284
+ if (account.isEmpty) {
285
+ setDialogState(() {
286
+ inlineError = 'Enter your username or email.';
287
+ });
288
+ return;
289
+ }
290
+ final sent = await widget.controller
291
+ .requestPasswordReset(account);
292
+ if (sent && dialogContext.mounted) {
293
+ Navigator.of(dialogContext).pop();
294
+ }
295
+ },
296
+ child: widget.controller.isAuthenticating
297
+ ? const SizedBox.square(
298
+ dimension: 16,
299
+ child: CircularProgressIndicator(strokeWidth: 2),
300
+ )
301
+ : Text('Send link'),
302
+ ),
303
+ ],
304
+ );
305
+ },
306
+ );
307
+ },
308
+ );
309
+ } finally {
310
+ accountController.dispose();
311
+ }
312
+ }
313
+
314
+ Future<void> _showQrLoginDialog() async {
315
+ _qrAutoRequestedForVisibleMode = true;
316
+ await widget.controller.prepareQrLoginChallenge();
317
+ if (!mounted) {
318
+ return;
319
+ }
320
+ final controller = widget.controller;
321
+ await showDialog<void>(
322
+ context: context,
323
+ builder: (dialogContext) {
324
+ return StatefulBuilder(
325
+ builder: (context, setDialogState) {
326
+ final challenge = controller.qrLoginChallenge;
327
+ final canShowQr =
328
+ challenge?.isUsable == true && !(challenge?.isExpired ?? true);
329
+ final countdown = challenge?.secondsRemaining ?? 0;
330
+
331
+ Widget buildQrSurface() {
332
+ return Container(
333
+ width: double.infinity,
334
+ padding: const EdgeInsets.all(18),
335
+ decoration: BoxDecoration(
336
+ color: Colors.white,
337
+ borderRadius: BorderRadius.circular(24),
338
+ ),
339
+ child: AspectRatio(
340
+ aspectRatio: 1,
341
+ child: Center(
342
+ child: canShowQr
343
+ ? QrImageView(
344
+ data: challenge!.qrPayload,
345
+ version: QrVersions.auto,
346
+ eyeStyle: const QrEyeStyle(
347
+ eyeShape: QrEyeShape.square,
348
+ color: Color(0xFF04111D),
349
+ ),
350
+ dataModuleStyle: const QrDataModuleStyle(
351
+ dataModuleShape: QrDataModuleShape.square,
352
+ color: Color(0xFF04111D),
353
+ ),
354
+ )
355
+ : controller.isPreparingQrLogin
356
+ ? const SizedBox.square(
357
+ dimension: 40,
358
+ child: CircularProgressIndicator(strokeWidth: 3),
359
+ )
360
+ : Icon(
361
+ Icons.qr_code_2_rounded,
362
+ size: 84,
363
+ color: _textMuted,
364
+ ),
365
+ ),
366
+ ),
367
+ );
368
+ }
369
+
370
+ return AlertDialog(
371
+ backgroundColor: _bgCard,
372
+ title: const Text('Pair with QR code'),
373
+ content: SizedBox(
374
+ width: 360,
375
+ child: Column(
376
+ mainAxisSize: MainAxisSize.min,
377
+ crossAxisAlignment: CrossAxisAlignment.stretch,
378
+ children: <Widget>[
379
+ Text(
380
+ 'Open Account settings on a signed-in Android device, scan this code, and approve the login.',
381
+ style: TextStyle(color: _textSecondary, height: 1.45),
382
+ ),
383
+ const SizedBox(height: 16),
384
+ buildQrSurface(),
385
+ const SizedBox(height: 14),
386
+ _InfoChip(
387
+ icon: Icons.timer_outlined,
388
+ label: canShowQr
389
+ ? 'Refreshes in ${countdown}s'
390
+ : 'Waiting for code',
391
+ ),
392
+ if (controller.qrLoginErrorMessage != null) ...<Widget>[
393
+ const SizedBox(height: 12),
394
+ _InlineError(message: controller.qrLoginErrorMessage!),
395
+ ],
396
+ ],
397
+ ),
398
+ ),
399
+ actions: <Widget>[
400
+ TextButton(
401
+ onPressed: controller.isPreparingQrLogin
402
+ ? null
403
+ : () async {
404
+ _qrAutoRequestedForVisibleMode = true;
405
+ await widget.controller.prepareQrLoginChallenge(
406
+ force: true,
407
+ );
408
+ if (mounted) {
409
+ setDialogState(() {});
410
+ }
411
+ },
412
+ child: const Text('Refresh code'),
413
+ ),
414
+ FilledButton(
415
+ onPressed: () => Navigator.of(dialogContext).pop(),
416
+ child: const Text('Close'),
417
+ ),
418
+ ],
419
+ );
420
+ },
421
+ );
422
+ },
423
+ );
424
+ }
425
+
426
+ void _ensureQrLoginChallenge({bool force = false}) {
427
+ if (!mounted) return;
428
+ if (force) {
429
+ _qrAutoRequestedForVisibleMode = true;
430
+ }
431
+ unawaited(widget.controller.prepareQrLoginChallenge(force: force));
432
+ }
433
+
434
+ Widget _buildAuthFormPane({
435
+ required NeoAgentController controller,
436
+ required List<AuthProviderCatalogItem> availableProviders,
437
+ required bool awaitingTwoFactor,
438
+ required bool showRegisterToggle,
439
+ required String title,
440
+ required String subtitle,
441
+ }) {
442
+ return Column(
443
+ mainAxisSize: MainAxisSize.min,
444
+ crossAxisAlignment: CrossAxisAlignment.stretch,
445
+ children: <Widget>[
446
+ Column(children: <Widget>[const _BrandLockup(logoSize: 58)]),
447
+ const SizedBox(height: 26),
448
+ Text(
449
+ awaitingTwoFactor ? 'Verification' : title.toUpperCase(),
450
+ style: _sectionEyebrowStyle(),
451
+ ),
452
+ const SizedBox(height: 8),
453
+ Text(
454
+ awaitingTwoFactor ? 'Enter 2FA code' : title,
455
+ style: _displayTitleStyle(30),
456
+ ),
457
+ const SizedBox(height: 8),
458
+ Text(
459
+ awaitingTwoFactor
460
+ ? 'Open your authenticator app and enter the current NeoOS code.'
461
+ : subtitle,
462
+ style: TextStyle(color: _textSecondary, height: 1.5),
463
+ ),
464
+ const SizedBox(height: 20),
465
+ if (controller.errorMessage != null) ...<Widget>[
466
+ _InlineError(message: controller.errorMessage!),
467
+ const SizedBox(height: 16),
468
+ ],
469
+ if (controller.authInfoMessage != null) ...<Widget>[
470
+ _InlineSuccess(message: controller.authInfoMessage!),
471
+ const SizedBox(height: 24),
472
+ ],
473
+ if (awaitingTwoFactor) ...<Widget>[
474
+ TextField(
475
+ controller: _twoFactorController,
476
+ keyboardType: TextInputType.number,
477
+ decoration: const InputDecoration(
478
+ labelText: '2FA or recovery code',
479
+ ),
480
+ ),
481
+ ] else ...<Widget>[
482
+ TextField(
483
+ controller: _usernameController,
484
+ onChanged: (_) => setState(() {}),
485
+ decoration: const InputDecoration(labelText: 'Username'),
486
+ ),
487
+ const SizedBox(height: 14),
488
+ TextField(
489
+ controller: _passwordController,
490
+ onChanged: (_) => setState(() {}),
491
+ obscureText: true,
492
+ decoration: const InputDecoration(labelText: 'Password'),
493
+ ),
494
+ if (_registerMode) ...<Widget>[
495
+ const SizedBox(height: 10),
496
+ _PasswordStrengthIndicator(
497
+ info: _passwordStrengthInfo(
498
+ password: _passwordController.text,
499
+ username: _usernameController.text,
500
+ email: _emailController.text,
501
+ ),
502
+ ),
503
+ const SizedBox(height: 14),
504
+ TextField(
505
+ controller: _emailController,
506
+ onChanged: (_) => setState(() {}),
507
+ keyboardType: TextInputType.emailAddress,
508
+ autofillHints: const <String>[AutofillHints.email],
509
+ decoration: const InputDecoration(labelText: 'Email'),
510
+ ),
511
+ const SizedBox(height: 14),
512
+ TextField(
513
+ controller: _confirmPasswordController,
514
+ obscureText: true,
515
+ decoration: const InputDecoration(labelText: 'Confirm Password'),
516
+ ),
517
+ ],
518
+ ],
519
+ const SizedBox(height: 22),
520
+ FilledButton(
521
+ onPressed: controller.isAuthenticating
522
+ ? null
523
+ : () async {
524
+ if (awaitingTwoFactor) {
525
+ await controller.completeTwoFactorLogin(
526
+ code: _twoFactorController.text,
527
+ );
528
+ return;
529
+ }
530
+ if (_registerMode &&
531
+ _passwordController.text !=
532
+ _confirmPasswordController.text) {
533
+ widget.controller.showInlineError(
534
+ 'Passwords do not match.',
535
+ );
536
+ return;
537
+ }
538
+ if (_registerMode) {
539
+ await controller.register(
540
+ username: _usernameController.text,
541
+ email: _emailController.text,
542
+ password: _passwordController.text,
543
+ );
544
+ } else {
545
+ await controller.login(
546
+ username: _usernameController.text,
547
+ password: _passwordController.text,
548
+ );
549
+ }
550
+ },
551
+ style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(58)),
552
+ child: controller.isAuthenticating
553
+ ? const SizedBox.square(
554
+ dimension: 20,
555
+ child: CircularProgressIndicator(
556
+ strokeWidth: 2,
557
+ color: Colors.white,
558
+ ),
559
+ )
560
+ : Text(
561
+ awaitingTwoFactor
562
+ ? 'Verify'
563
+ : (_registerMode ? 'Create account' : 'Sign in'),
564
+ ),
565
+ ),
566
+ if (awaitingTwoFactor) ...<Widget>[
567
+ const SizedBox(height: 12),
568
+ TextButton(
569
+ onPressed: controller.isAuthenticating
570
+ ? null
571
+ : controller.cancelTwoFactorLogin,
572
+ child: const Text('Back to sign in'),
573
+ ),
574
+ ] else ...<Widget>[
575
+ if (availableProviders.isNotEmpty) ...<Widget>[
576
+ const SizedBox(height: 16),
577
+ Row(
578
+ children: <Widget>[
579
+ Expanded(child: Divider(color: _borderLight)),
580
+ Padding(
581
+ padding: const EdgeInsets.symmetric(horizontal: 10),
582
+ child: Text(
583
+ 'or continue with',
584
+ style: TextStyle(color: _textSecondary, fontSize: 12),
585
+ ),
586
+ ),
587
+ Expanded(child: Divider(color: _borderLight)),
588
+ ],
589
+ ),
590
+ const SizedBox(height: 14),
591
+ ...availableProviders.map(
592
+ (provider) => Padding(
593
+ padding: const EdgeInsets.only(bottom: 10),
594
+ child: OutlinedButton.icon(
595
+ onPressed: controller.isAuthenticating
596
+ ? null
597
+ : () => controller.authenticateWithProvider(
598
+ provider: provider.id,
599
+ register: _registerMode,
600
+ ),
601
+ icon: provider.icon == 'google'
602
+ ? const Text(
603
+ 'G',
604
+ style: TextStyle(
605
+ fontSize: 18,
606
+ fontWeight: FontWeight.w700,
607
+ color: Color(0xFF4285F4),
608
+ ),
609
+ )
610
+ : const Icon(Icons.link),
611
+ label: Text(
612
+ _registerMode
613
+ ? 'Register with ${provider.label}'
614
+ : 'Sign in with ${provider.label}',
615
+ ),
616
+ style: OutlinedButton.styleFrom(
617
+ minimumSize: const Size.fromHeight(54),
618
+ backgroundColor: _bgPrimary.withValues(alpha: 0.18),
619
+ ),
620
+ ),
621
+ ),
622
+ ),
623
+ ],
624
+ if (!_registerMode && controller.serviceEmailConfigured) ...<Widget>[
625
+ const SizedBox(height: 12),
626
+ TextButton(
627
+ onPressed: controller.isAuthenticating
628
+ ? null
629
+ : _showForgotPasswordDialog,
630
+ child: const Text('Forgot password?'),
631
+ ),
632
+ if (!showRegisterToggle) const SizedBox(height: 12),
633
+ ],
634
+ if (showRegisterToggle) ...<Widget>[
635
+ const SizedBox(height: 12),
636
+ TextButton(
637
+ onPressed: controller.isAuthenticating
638
+ ? null
639
+ : () {
640
+ setState(() {
641
+ _registerMode = !_registerMode;
642
+ });
643
+ if (!_registerMode) {
644
+ _qrAutoRequestedForVisibleMode = false;
645
+ _ensureQrLoginChallenge(force: true);
646
+ }
647
+ },
648
+ child: Text(
649
+ _registerMode
650
+ ? 'Already have an account? Sign in'
651
+ : 'Need a new account? Register',
652
+ ),
653
+ ),
654
+ ],
655
+ ],
656
+ ],
657
+ );
658
+ }
659
+
660
+ Widget _buildQrLoginPane(NeoAgentController controller) {
661
+ final challenge = controller.qrLoginChallenge;
662
+ final countdown = challenge?.secondsRemaining ?? 0;
663
+ final canShowQr =
664
+ challenge?.isUsable == true && !(challenge?.isExpired ?? true);
665
+ return LayoutBuilder(
666
+ builder: (context, constraints) {
667
+ final compact = constraints.maxWidth < 420;
668
+ final narrow = constraints.maxWidth < 520;
669
+ final showInlineQr = !narrow;
670
+ final panelPadding = compact ? 18.0 : 24.0;
671
+ final qrShellPadding = compact ? 14.0 : 18.0;
672
+ final qrCardPadding = compact ? 14.0 : 18.0;
673
+ final titleSize = compact ? 22.0 : 28.0;
674
+ final titleAlignment = compact ? TextAlign.center : TextAlign.left;
675
+ final contentAlignment = compact
676
+ ? CrossAxisAlignment.center
677
+ : CrossAxisAlignment.start;
678
+
679
+ Widget buildInfoSection() {
680
+ return _InfoChip(
681
+ icon: Icons.timer_outlined,
682
+ label: canShowQr
683
+ ? 'Refreshes in ${countdown}s'
684
+ : 'Waiting for code',
685
+ );
686
+ }
687
+
688
+ return Container(
689
+ decoration: BoxDecoration(
690
+ borderRadius: BorderRadius.circular(compact ? 24 : 28),
691
+ gradient: LinearGradient(
692
+ begin: Alignment.topLeft,
693
+ end: Alignment.bottomRight,
694
+ colors: <Color>[
695
+ const Color(0xFF0A1D2E),
696
+ _bgSecondary.withValues(alpha: 0.96),
697
+ const Color(0xFF112B43),
698
+ ],
699
+ ),
700
+ border: Border.all(color: _borderLight.withValues(alpha: 0.45)),
701
+ boxShadow: <BoxShadow>[
702
+ BoxShadow(
703
+ color: const Color(0xFF6EDBFF).withValues(alpha: 0.12),
704
+ blurRadius: 36,
705
+ spreadRadius: 2,
706
+ ),
707
+ ],
708
+ ),
709
+ child: Stack(
710
+ children: <Widget>[
711
+ Positioned(
712
+ top: compact ? -18 : -24,
713
+ right: compact ? -24 : -12,
714
+ child: IgnorePointer(
715
+ child: Container(
716
+ width: compact ? 86 : 120,
717
+ height: compact ? 86 : 120,
718
+ decoration: BoxDecoration(
719
+ shape: BoxShape.circle,
720
+ color: const Color(0xFF6EDBFF).withValues(alpha: 0.12),
721
+ ),
722
+ ),
723
+ ),
724
+ ),
725
+ Positioned(
726
+ bottom: compact ? -34 : -36,
727
+ left: compact ? -28 : -18,
728
+ child: IgnorePointer(
729
+ child: Container(
730
+ width: compact ? 110 : 140,
731
+ height: compact ? 110 : 140,
732
+ decoration: BoxDecoration(
733
+ shape: BoxShape.circle,
734
+ color: const Color(0xFF58E0A2).withValues(alpha: 0.10),
735
+ ),
736
+ ),
737
+ ),
738
+ ),
739
+ Padding(
740
+ padding: EdgeInsets.all(panelPadding),
741
+ child: Column(
742
+ crossAxisAlignment: contentAlignment,
743
+ children: <Widget>[
744
+ Text(
745
+ 'Scan with NeoOS on your phone',
746
+ textAlign: titleAlignment,
747
+ style: GoogleFonts.spaceGrotesk(
748
+ fontSize: titleSize,
749
+ fontWeight: FontWeight.w700,
750
+ letterSpacing: compact ? -0.3 : -0.6,
751
+ color: Colors.white,
752
+ height: compact ? 1.05 : null,
753
+ ),
754
+ ),
755
+ const SizedBox(height: 10),
756
+ ConstrainedBox(
757
+ constraints: const BoxConstraints(maxWidth: 440),
758
+ child: Text(
759
+ 'On a signed-in Android device, open Account settings, scan this code, and approve the login.',
760
+ textAlign: titleAlignment,
761
+ style: TextStyle(
762
+ color: Colors.white.withValues(alpha: 0.78),
763
+ height: 1.5,
764
+ ),
765
+ ),
766
+ ),
767
+ SizedBox(height: compact ? 18 : 22),
768
+ Container(
769
+ width: double.infinity,
770
+ padding: EdgeInsets.all(qrShellPadding),
771
+ decoration: BoxDecoration(
772
+ color: Colors.white.withValues(alpha: 0.08),
773
+ borderRadius: BorderRadius.circular(compact ? 20 : 24),
774
+ border: Border.all(
775
+ color: Colors.white.withValues(alpha: 0.12),
776
+ ),
777
+ ),
778
+ child: Column(
779
+ children: <Widget>[
780
+ if (showInlineQr)
781
+ Container(
782
+ width: double.infinity,
783
+ padding: EdgeInsets.all(qrCardPadding),
784
+ decoration: BoxDecoration(
785
+ color: Colors.white,
786
+ borderRadius: BorderRadius.circular(
787
+ compact ? 18 : 22,
788
+ ),
789
+ boxShadow: <BoxShadow>[
790
+ BoxShadow(
791
+ color: Colors.black.withValues(alpha: 0.12),
792
+ blurRadius: 26,
793
+ offset: const Offset(0, 10),
794
+ ),
795
+ ],
796
+ ),
797
+ child: AspectRatio(
798
+ aspectRatio: 1,
799
+ child: Center(
800
+ child: canShowQr
801
+ ? QrImageView(
802
+ data: challenge!.qrPayload,
803
+ version: QrVersions.auto,
804
+ eyeStyle: const QrEyeStyle(
805
+ eyeShape: QrEyeShape.square,
806
+ color: Color(0xFF04111D),
807
+ ),
808
+ dataModuleStyle:
809
+ const QrDataModuleStyle(
810
+ dataModuleShape:
811
+ QrDataModuleShape.square,
812
+ color: Color(0xFF04111D),
813
+ ),
814
+ )
815
+ : controller.isPreparingQrLogin
816
+ ? const SizedBox.square(
817
+ dimension: 40,
818
+ child: CircularProgressIndicator(
819
+ strokeWidth: 3,
820
+ ),
821
+ )
822
+ : Icon(
823
+ Icons.qr_code_2_rounded,
824
+ size: narrow ? 72 : 84,
825
+ color: _textMuted,
826
+ ),
827
+ ),
828
+ ),
829
+ )
830
+ else
831
+ SizedBox(
832
+ width: double.infinity,
833
+ child: FilledButton.icon(
834
+ onPressed: controller.isPreparingQrLogin
835
+ ? null
836
+ : _showQrLoginDialog,
837
+ style: FilledButton.styleFrom(
838
+ minimumSize: const Size.fromHeight(56),
839
+ backgroundColor: Colors.white,
840
+ foregroundColor: const Color(0xFF04111D),
841
+ ),
842
+ icon: controller.isPreparingQrLogin
843
+ ? const SizedBox.square(
844
+ dimension: 16,
845
+ child: CircularProgressIndicator(
846
+ strokeWidth: 2,
847
+ color: Color(0xFF04111D),
848
+ ),
849
+ )
850
+ : const Icon(Icons.qr_code_2_rounded),
851
+ label: Text(
852
+ canShowQr
853
+ ? 'Show QR code'
854
+ : 'Prepare QR code',
855
+ ),
856
+ ),
857
+ ),
858
+ const SizedBox(height: 16),
859
+ buildInfoSection(),
860
+ ],
861
+ ),
862
+ ),
863
+ if (controller.qrLoginErrorMessage != null) ...<Widget>[
864
+ const SizedBox(height: 14),
865
+ _InlineError(message: controller.qrLoginErrorMessage!),
866
+ ],
867
+ const SizedBox(height: 16),
868
+ Text(
869
+ 'Approval stays inside your authenticated mobile session, and each code expires automatically after a short window.',
870
+ textAlign: titleAlignment,
871
+ style: TextStyle(
872
+ color: Colors.white.withValues(alpha: 0.68),
873
+ height: 1.45,
874
+ ),
875
+ ),
876
+ const SizedBox(height: 18),
877
+ SizedBox(
878
+ width: double.infinity,
879
+ child: OutlinedButton.icon(
880
+ onPressed: controller.isPreparingQrLogin
881
+ ? null
882
+ : () => _ensureQrLoginChallenge(force: true),
883
+ icon: controller.isPreparingQrLogin
884
+ ? const SizedBox.square(
885
+ dimension: 16,
886
+ child: CircularProgressIndicator(
887
+ strokeWidth: 2,
888
+ ),
889
+ )
890
+ : const Icon(Icons.refresh_rounded),
891
+ label: const Text('Refresh code'),
892
+ style: OutlinedButton.styleFrom(
893
+ minimumSize: const Size.fromHeight(52),
894
+ foregroundColor: Colors.white,
895
+ side: BorderSide(
896
+ color: Colors.white.withValues(alpha: 0.18),
897
+ ),
898
+ ),
899
+ ),
900
+ ),
901
+ ],
902
+ ),
903
+ ),
904
+ ],
905
+ ),
906
+ );
907
+ },
908
+ );
909
+ }
910
+
911
+ @override
912
+ Widget build(BuildContext context) {
913
+ final controller = widget.controller;
914
+ final availableProviders = controller.authProviders
915
+ .where((provider) => provider.configured)
916
+ .toList();
917
+ if (!controller.hasUser) {
918
+ _registerMode = true;
919
+ }
920
+
921
+ final title = _registerMode
922
+ ? (controller.hasUser ? 'Create account' : 'Create the first account')
923
+ : 'Sign in';
924
+ final subtitle = _registerMode
925
+ ? (controller.hasUser
926
+ ? 'Create another NeoOS account.'
927
+ : 'This account will unlock NeoOS on this machine.')
928
+ : 'Enter your NeoOS account details.';
929
+ final awaitingTwoFactor = controller.isAwaitingTwoFactor;
930
+ final showRegisterToggle =
931
+ controller.registrationOpen && controller.hasUser;
932
+ final showQrLogin = !awaitingTwoFactor && !_registerMode;
933
+
934
+ if (showQrLogin &&
935
+ !controller.isPreparingQrLogin &&
936
+ !controller.isAuthenticated &&
937
+ !_qrAutoRequestedForVisibleMode) {
938
+ _qrAutoRequestedForVisibleMode = true;
939
+ WidgetsBinding.instance.addPostFrameCallback((_) {
940
+ _ensureQrLoginChallenge();
941
+ });
942
+ }
943
+ if (!showQrLogin) {
944
+ _qrAutoRequestedForVisibleMode = false;
945
+ }
946
+
947
+ return _AmbientBackdrop(
948
+ child: Scaffold(
949
+ backgroundColor: Colors.transparent,
950
+ body: SafeArea(
951
+ child: LayoutBuilder(
952
+ builder: (context, viewportConstraints) {
953
+ return SingleChildScrollView(
954
+ child: ConstrainedBox(
955
+ constraints: BoxConstraints(
956
+ minHeight: viewportConstraints.maxHeight,
957
+ ),
958
+ child: Padding(
959
+ padding: EdgeInsets.all(
960
+ viewportConstraints.maxWidth < 480 ? 14 : 24,
961
+ ),
962
+ child: Center(
963
+ child: ConstrainedBox(
964
+ constraints: BoxConstraints(
965
+ maxWidth: showQrLogin ? 980 : 468,
966
+ ),
967
+ child: Container(
968
+ decoration: BoxDecoration(
969
+ gradient: _panelGradient,
970
+ borderRadius: BorderRadius.circular(32),
971
+ border: Border.all(color: _borderLight),
972
+ boxShadow: _softPanelShadow,
973
+ ),
974
+ child: Padding(
975
+ padding: EdgeInsets.fromLTRB(
976
+ viewportConstraints.maxWidth < 480 ? 18 : 34,
977
+ viewportConstraints.maxWidth < 480 ? 20 : 30,
978
+ viewportConstraints.maxWidth < 480 ? 18 : 34,
979
+ viewportConstraints.maxWidth < 480 ? 20 : 30,
980
+ ),
981
+ child: LayoutBuilder(
982
+ builder: (context, panelConstraints) {
983
+ final useWideQrLayout =
984
+ showQrLogin &&
985
+ panelConstraints.maxWidth >= 820;
986
+ final formPane = _buildAuthFormPane(
987
+ controller: controller,
988
+ availableProviders: availableProviders,
989
+ awaitingTwoFactor: awaitingTwoFactor,
990
+ showRegisterToggle: showRegisterToggle,
991
+ title: title,
992
+ subtitle: subtitle,
993
+ );
994
+ if (!showQrLogin) {
995
+ return formPane;
996
+ }
997
+ if (useWideQrLayout) {
998
+ return Row(
999
+ crossAxisAlignment:
1000
+ CrossAxisAlignment.start,
1001
+ children: <Widget>[
1002
+ Expanded(flex: 11, child: formPane),
1003
+ const SizedBox(width: 24),
1004
+ Expanded(
1005
+ flex: 10,
1006
+ child: _buildQrLoginPane(controller),
1007
+ ),
1008
+ ],
1009
+ );
1010
+ }
1011
+ return Column(
1012
+ mainAxisSize: MainAxisSize.min,
1013
+ crossAxisAlignment:
1014
+ CrossAxisAlignment.stretch,
1015
+ children: <Widget>[
1016
+ formPane,
1017
+ const SizedBox(height: 22),
1018
+ _buildQrLoginPane(controller),
1019
+ ],
1020
+ );
1021
+ },
1022
+ ),
1023
+ ),
1024
+ ),
1025
+ ),
1026
+ ),
1027
+ ),
1028
+ ),
1029
+ );
1030
+ },
1031
+ ),
1032
+ ),
1033
+ ),
1034
+ );
1035
+ }
1036
+ }
1037
+
1038
+ class HomeView extends StatefulWidget {
1039
+ const HomeView({super.key, required this.controller});
1040
+
1041
+ final NeoAgentController controller;
1042
+
1043
+ @override
1044
+ State<HomeView> createState() => _HomeViewState();
1045
+ }
1046
+
1047
+ class _HomeViewState extends State<HomeView> {
1048
+ bool _blockedDialogOpen = false;
1049
+ SidebarGroup? _expandedSidebarGroup;
1050
+ AppSection? _lastSelectedSection;
1051
+
1052
+ @override
1053
+ void initState() {
1054
+ super.initState();
1055
+
1056
+ // Initialize Proactive Context Features for mobile
1057
+ if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
1058
+ final backendUrl = widget.controller.backendUrl;
1059
+ final sessionCookie = widget.controller.sessionCookie ?? '';
1060
+
1061
+ LocationService().initialize(context).then((_) {
1062
+ LocationService().startGeofenceTracking(backendUrl, sessionCookie);
1063
+ });
1064
+
1065
+ if (Platform.isAndroid) {
1066
+ NotificationInterceptor().initialize(context, backendUrl, sessionCookie);
1067
+ }
1068
+ }
1069
+
1070
+ _lastSelectedSection = widget.controller.selectedSection;
1071
+ _expandedSidebarGroup = _sidebarGroupForSection(
1072
+ widget.controller.selectedSection,
1073
+ );
1074
+ widget.controller.addListener(_handleControllerChanged);
1075
+ }
1076
+
1077
+ @override
1078
+ void didUpdateWidget(covariant HomeView oldWidget) {
1079
+ super.didUpdateWidget(oldWidget);
1080
+ if (oldWidget.controller != widget.controller) {
1081
+ oldWidget.controller.removeListener(_handleControllerChanged);
1082
+ widget.controller.addListener(_handleControllerChanged);
1083
+ _lastSelectedSection = widget.controller.selectedSection;
1084
+ _expandedSidebarGroup = _sidebarGroupForSection(
1085
+ widget.controller.selectedSection,
1086
+ );
1087
+ }
1088
+ }
1089
+
1090
+ SidebarGroup? _sidebarGroupForSection(AppSection section) {
1091
+ if (!_mainSections(widget.controller).contains(section)) {
1092
+ return null;
1093
+ }
1094
+ return section.group;
1095
+ }
1096
+
1097
+ void _handleControllerChanged() {
1098
+ if (!mounted) {
1099
+ return;
1100
+ }
1101
+ final nextSection = widget.controller.selectedSection;
1102
+ setState(() {
1103
+ if (_lastSelectedSection != nextSection) {
1104
+ final oldGroup = _lastSelectedSection == null
1105
+ ? null
1106
+ : _sidebarGroupForSection(_lastSelectedSection!);
1107
+ final nextGroup = _sidebarGroupForSection(nextSection);
1108
+ if (oldGroup != nextGroup) {
1109
+ _expandedSidebarGroup = nextGroup;
1110
+ }
1111
+ _lastSelectedSection = nextSection;
1112
+ }
1113
+ });
1114
+ }
1115
+
1116
+ void _toggleSidebarGroup(SidebarGroup group) {
1117
+ setState(() {
1118
+ _expandedSidebarGroup = _expandedSidebarGroup == group ? null : group;
1119
+ });
1120
+ }
1121
+
1122
+ @override
1123
+ void dispose() {
1124
+ widget.controller.removeListener(_handleControllerChanged);
1125
+ super.dispose();
1126
+ }
1127
+
1128
+ @override
1129
+ Widget build(BuildContext context) {
1130
+ final controller = widget.controller;
1131
+ final pendingBlockedSender = controller.pendingBlockedSenderNotice;
1132
+
1133
+ if (!_blockedDialogOpen && pendingBlockedSender != null) {
1134
+ WidgetsBinding.instance.addPostFrameCallback((_) {
1135
+ if (!mounted || _blockedDialogOpen) {
1136
+ return;
1137
+ }
1138
+ _showBlockedSenderDialog(pendingBlockedSender);
1139
+ });
1140
+ }
1141
+
1142
+ final wide = MediaQuery.sizeOf(context).width >= 1080;
1143
+
1144
+ if (wide) {
1145
+ return _AmbientBackdrop(
1146
+ child: Scaffold(
1147
+ backgroundColor: Colors.transparent,
1148
+ body: SafeArea(
1149
+ child: Padding(
1150
+ padding: const EdgeInsets.all(14),
1151
+ child: Row(
1152
+ children: <Widget>[
1153
+ _Sidebar(
1154
+ controller: controller,
1155
+ expandedGroup: _expandedSidebarGroup,
1156
+ onToggleGroup: _toggleSidebarGroup,
1157
+ ),
1158
+ const SizedBox(width: 14),
1159
+ Expanded(
1160
+ child: Container(
1161
+ decoration: BoxDecoration(
1162
+ gradient: _panelGradient,
1163
+ borderRadius: BorderRadius.circular(32),
1164
+ border: Border.all(color: _borderLight),
1165
+ boxShadow: _softPanelShadow,
1166
+ ),
1167
+ child: ClipRRect(
1168
+ borderRadius: BorderRadius.circular(32),
1169
+ child: AnimatedSwitcher(
1170
+ duration: const Duration(milliseconds: 260),
1171
+ switchInCurve: Curves.easeOutCubic,
1172
+ switchOutCurve: Curves.easeInCubic,
1173
+ transitionBuilder: (child, animation) {
1174
+ final offset = Tween<Offset>(
1175
+ begin: const Offset(0.015, 0.02),
1176
+ end: Offset.zero,
1177
+ ).animate(animation);
1178
+ return FadeTransition(
1179
+ opacity: animation,
1180
+ child: SlideTransition(
1181
+ position: offset,
1182
+ child: child,
1183
+ ),
1184
+ );
1185
+ },
1186
+ child: KeyedSubtree(
1187
+ key: ValueKey<AppSection>(
1188
+ controller.selectedSection,
1189
+ ),
1190
+ child: _SectionBody(controller: controller),
1191
+ ),
1192
+ ),
1193
+ ),
1194
+ ),
1195
+ ),
1196
+ ],
1197
+ ),
1198
+ ),
1199
+ ),
1200
+ ),
1201
+ );
1202
+ }
1203
+
1204
+ return _AmbientBackdrop(
1205
+ child: Scaffold(
1206
+ backgroundColor: Colors.transparent,
1207
+ drawer: _MobileDrawer(
1208
+ controller: controller,
1209
+ expandedGroup: _expandedSidebarGroup,
1210
+ onToggleGroup: _toggleSidebarGroup,
1211
+ ),
1212
+ appBar: AppBar(
1213
+ title: Text(controller.selectedSection.navigationTitle),
1214
+ elevation: 0,
1215
+ ),
1216
+ body: SafeArea(
1217
+ child: Padding(
1218
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
1219
+ child: Container(
1220
+ decoration: BoxDecoration(
1221
+ gradient: _panelGradient,
1222
+ borderRadius: BorderRadius.circular(26),
1223
+ border: Border.all(color: _borderLight),
1224
+ boxShadow: _softPanelShadow,
1225
+ ),
1226
+ child: ClipRRect(
1227
+ borderRadius: BorderRadius.circular(26),
1228
+ child: AnimatedSwitcher(
1229
+ duration: const Duration(milliseconds: 240),
1230
+ switchInCurve: Curves.easeOutCubic,
1231
+ switchOutCurve: Curves.easeInCubic,
1232
+ child: KeyedSubtree(
1233
+ key: ValueKey<AppSection>(controller.selectedSection),
1234
+ child: _SectionBody(controller: controller),
1235
+ ),
1236
+ ),
1237
+ ),
1238
+ ),
1239
+ ),
1240
+ ),
1241
+ ),
1242
+ );
1243
+ }
1244
+
1245
+ Future<void> _showBlockedSenderDialog(BlockedSenderNotice notice) async {
1246
+ _blockedDialogOpen = true;
1247
+ try {
1248
+ await showDialog<void>(
1249
+ context: context,
1250
+ barrierDismissible: true,
1251
+ builder: (dialogContext) {
1252
+ return AlertDialog(
1253
+ backgroundColor: _bgCard,
1254
+ title: Text('Allow sender on ${notice.platform.toUpperCase()}?'),
1255
+ content: SizedBox(
1256
+ width: 520,
1257
+ child: SingleChildScrollView(
1258
+ child: Column(
1259
+ mainAxisSize: MainAxisSize.min,
1260
+ crossAxisAlignment: CrossAxisAlignment.start,
1261
+ children: <Widget>[
1262
+ Text(
1263
+ notice.senderLabel,
1264
+ style: TextStyle(
1265
+ fontSize: 16,
1266
+ fontWeight: FontWeight.w700,
1267
+ ),
1268
+ ),
1269
+ if (notice.meta.isNotEmpty) ...<Widget>[
1270
+ const SizedBox(height: 6),
1271
+ Text(
1272
+ notice.meta,
1273
+ style: TextStyle(color: _textSecondary),
1274
+ ),
1275
+ ],
1276
+ const SizedBox(height: 12),
1277
+ Text(
1278
+ 'This sender is currently blocked by the access list. You can allow them now or jump to Messaging to edit the full list.',
1279
+ style: TextStyle(color: _textSecondary, height: 1.45),
1280
+ ),
1281
+ if (notice.suggestions.isNotEmpty) ...<Widget>[
1282
+ const SizedBox(height: 18),
1283
+ ...notice.suggestions.map(
1284
+ (suggestion) => Padding(
1285
+ padding: const EdgeInsets.only(bottom: 10),
1286
+ child: SizedBox(
1287
+ width: double.infinity,
1288
+ child: FilledButton.icon(
1289
+ onPressed: () async {
1290
+ Navigator.of(dialogContext).pop();
1291
+ await widget.controller.allowMessagingSuggestion(
1292
+ notice.platform,
1293
+ suggestion,
1294
+ );
1295
+ },
1296
+ icon: Icon(Icons.verified_user_outlined),
1297
+ label: Text(suggestion.label),
1298
+ ),
1299
+ ),
1300
+ ),
1301
+ ),
1302
+ ],
1303
+ ],
1304
+ ),
1305
+ ),
1306
+ ),
1307
+ actions: <Widget>[
1308
+ TextButton(
1309
+ onPressed: () {
1310
+ widget.controller.setSelectedSection(AppSection.messaging);
1311
+ Navigator.of(dialogContext).pop();
1312
+ },
1313
+ child: Text('Open Messaging'),
1314
+ ),
1315
+ TextButton(
1316
+ onPressed: () => Navigator.of(dialogContext).pop(),
1317
+ child: Text('Dismiss'),
1318
+ ),
1319
+ ],
1320
+ );
1321
+ },
1322
+ );
1323
+ } finally {
1324
+ widget.controller.consumeBlockedSenderNotice(notice.id);
1325
+ if (mounted) {
1326
+ setState(() => _blockedDialogOpen = false);
1327
+ } else {
1328
+ _blockedDialogOpen = false;
1329
+ }
1330
+ }
1331
+ }
1332
+ }
1333
+
1334
+ class _Sidebar extends StatelessWidget {
1335
+ const _Sidebar({
1336
+ required this.controller,
1337
+ required this.expandedGroup,
1338
+ required this.onToggleGroup,
1339
+ });
1340
+
1341
+ final NeoAgentController controller;
1342
+ final SidebarGroup? expandedGroup;
1343
+ final ValueChanged<SidebarGroup> onToggleGroup;
1344
+
1345
+ @override
1346
+ Widget build(BuildContext context) {
1347
+ return Container(
1348
+ width: 254,
1349
+ decoration: BoxDecoration(
1350
+ gradient: LinearGradient(
1351
+ colors: <Color>[
1352
+ _bgSecondary.withValues(alpha: 0.96),
1353
+ _bgTertiary.withValues(alpha: 0.92),
1354
+ ],
1355
+ begin: Alignment.topCenter,
1356
+ end: Alignment.bottomCenter,
1357
+ ),
1358
+ borderRadius: BorderRadius.circular(30),
1359
+ border: Border.all(color: _borderLight),
1360
+ boxShadow: _softPanelShadow,
1361
+ ),
1362
+ child: Column(
1363
+ children: <Widget>[
1364
+ Container(
1365
+ padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
1366
+ decoration: BoxDecoration(
1367
+ border: Border(bottom: BorderSide(color: _border)),
1368
+ ),
1369
+ child: Column(
1370
+ crossAxisAlignment: CrossAxisAlignment.start,
1371
+ children: <Widget>[
1372
+ Row(
1373
+ children: <Widget>[
1374
+ Expanded(
1375
+ child: const _BrandLockup(
1376
+ logoSize: 34,
1377
+ titleFontSize: 18,
1378
+ direction: Axis.horizontal,
1379
+ spacing: 12,
1380
+ alignment: CrossAxisAlignment.start,
1381
+ ),
1382
+ ),
1383
+ ],
1384
+ ),
1385
+ ],
1386
+ ),
1387
+ ),
1388
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
1389
+ Padding(
1390
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
1391
+ child: _AgentSwitcher(controller: controller),
1392
+ ),
1393
+ ],
1394
+ Expanded(
1395
+ child: ListView(
1396
+ padding: const EdgeInsets.all(8),
1397
+ children: _buildSidebarItems(
1398
+ controller,
1399
+ onSelect: controller.setSelectedSection,
1400
+ expandedGroup: expandedGroup,
1401
+ onToggleGroup: onToggleGroup,
1402
+ ),
1403
+ ),
1404
+ ),
1405
+ Container(
1406
+ padding: const EdgeInsets.all(10),
1407
+ decoration: BoxDecoration(
1408
+ border: Border(top: BorderSide(color: _border)),
1409
+ ),
1410
+ child: Column(
1411
+ children: <Widget>[
1412
+ Row(
1413
+ children: <Widget>[
1414
+ Expanded(
1415
+ child: Text(
1416
+ controller.accountLabel,
1417
+ maxLines: 1,
1418
+ overflow: TextOverflow.ellipsis,
1419
+ style: TextStyle(
1420
+ color: _textSecondary,
1421
+ fontSize: 12,
1422
+ fontWeight: FontWeight.w600,
1423
+ ),
1424
+ ),
1425
+ ),
1426
+ const SizedBox(width: 8),
1427
+ _ProfileSettingsButton(
1428
+ controller: controller,
1429
+ onTap: () => controller.setSelectedSection(
1430
+ AppSection.accountSettings,
1431
+ ),
1432
+ ),
1433
+ const SizedBox(width: 8),
1434
+ _SidebarIconButton(
1435
+ tooltip: 'Logout',
1436
+ icon: Icons.logout,
1437
+ onTap: controller.logout,
1438
+ ),
1439
+ ],
1440
+ ),
1441
+ ],
1442
+ ),
1443
+ ),
1444
+ ],
1445
+ ),
1446
+ );
1447
+ }
1448
+ }
1449
+
1450
+ class _AgentSwitcher extends StatelessWidget {
1451
+ const _AgentSwitcher({required this.controller, this.onChanged});
1452
+
1453
+ final NeoAgentController controller;
1454
+ final VoidCallback? onChanged;
1455
+
1456
+ @override
1457
+ Widget build(BuildContext context) {
1458
+ return DropdownButtonFormField<String>(
1459
+ initialValue: controller.selectedAgentId,
1460
+ isExpanded: true,
1461
+ decoration: const InputDecoration(
1462
+ labelText: 'Agent',
1463
+ prefixIcon: Icon(Icons.smart_toy_outlined, size: 18),
1464
+ contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 13),
1465
+ ),
1466
+ items: controller.agentProfiles
1467
+ .map(
1468
+ (agent) => DropdownMenuItem<String>(
1469
+ value: agent.id,
1470
+ child: Text(agent.label, overflow: TextOverflow.ellipsis),
1471
+ ),
1472
+ )
1473
+ .toList(),
1474
+ onChanged: (value) {
1475
+ if (value == null) return;
1476
+ onChanged?.call();
1477
+ unawaited(controller.switchAgent(value));
1478
+ },
1479
+ );
1480
+ }
1481
+ }
1482
+
1483
+ class _ProfileSettingsButton extends StatelessWidget {
1484
+ const _ProfileSettingsButton({required this.controller, required this.onTap});
1485
+
1486
+ final NeoAgentController controller;
1487
+ final VoidCallback onTap;
1488
+
1489
+ @override
1490
+ Widget build(BuildContext context) {
1491
+ final label = controller.accountLabel.trim();
1492
+ final initial = label.isEmpty ? 'N' : label.characters.first.toUpperCase();
1493
+ final active = controller.selectedSection == AppSection.accountSettings;
1494
+ return Tooltip(
1495
+ message: 'Account settings',
1496
+ child: InkWell(
1497
+ borderRadius: BorderRadius.circular(18),
1498
+ onTap: onTap,
1499
+ child: Stack(
1500
+ clipBehavior: Clip.none,
1501
+ children: <Widget>[
1502
+ Container(
1503
+ width: 34,
1504
+ height: 34,
1505
+ decoration: BoxDecoration(
1506
+ color: active ? _accentMuted : _bgCard,
1507
+ shape: BoxShape.circle,
1508
+ border: Border.all(color: active ? _accent : _borderLight),
1509
+ ),
1510
+ alignment: Alignment.center,
1511
+ child: Text(
1512
+ initial,
1513
+ style: TextStyle(
1514
+ color: active ? _accentHover : _textPrimary,
1515
+ fontWeight: FontWeight.w800,
1516
+ fontSize: 13,
1517
+ ),
1518
+ ),
1519
+ ),
1520
+ Positioned(
1521
+ right: -2,
1522
+ bottom: -2,
1523
+ child: Container(
1524
+ width: 17,
1525
+ height: 17,
1526
+ decoration: BoxDecoration(
1527
+ color: _bgSecondary,
1528
+ shape: BoxShape.circle,
1529
+ border: Border.all(color: active ? _accent : _borderLight),
1530
+ ),
1531
+ child: Icon(
1532
+ Icons.settings,
1533
+ size: 11,
1534
+ color: active ? _accentHover : _textSecondary,
1535
+ ),
1536
+ ),
1537
+ ),
1538
+ ],
1539
+ ),
1540
+ ),
1541
+ );
1542
+ }
1543
+ }
1544
+
1545
+ class _MobileDrawer extends StatelessWidget {
1546
+ const _MobileDrawer({
1547
+ required this.controller,
1548
+ required this.expandedGroup,
1549
+ required this.onToggleGroup,
1550
+ });
1551
+
1552
+ final NeoAgentController controller;
1553
+ final SidebarGroup? expandedGroup;
1554
+ final ValueChanged<SidebarGroup> onToggleGroup;
1555
+
1556
+ @override
1557
+ Widget build(BuildContext context) {
1558
+ return Drawer(
1559
+ backgroundColor: _bgSecondary,
1560
+ child: SafeArea(
1561
+ child: Column(
1562
+ children: <Widget>[
1563
+ Padding(
1564
+ padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
1565
+ child: Column(
1566
+ crossAxisAlignment: CrossAxisAlignment.start,
1567
+ children: <Widget>[
1568
+ Row(
1569
+ children: <Widget>[
1570
+ Expanded(
1571
+ child: const _BrandLockup(
1572
+ logoSize: 30,
1573
+ titleFontSize: 18,
1574
+ direction: Axis.horizontal,
1575
+ spacing: 10,
1576
+ alignment: CrossAxisAlignment.start,
1577
+ ),
1578
+ ),
1579
+ ],
1580
+ ),
1581
+ if (controller.agentProfiles.isNotEmpty) ...<Widget>[
1582
+ const SizedBox(height: 12),
1583
+ _AgentSwitcher(
1584
+ controller: controller,
1585
+ onChanged: () => Navigator.of(context).pop(),
1586
+ ),
1587
+ ],
1588
+ ],
1589
+ ),
1590
+ ),
1591
+ Expanded(
1592
+ child: ListView(
1593
+ padding: const EdgeInsets.symmetric(horizontal: 8),
1594
+ children: _buildSidebarItems(
1595
+ controller,
1596
+ onSelect: (section) {
1597
+ controller.setSelectedSection(section);
1598
+ Navigator.of(context).pop();
1599
+ },
1600
+ expandedGroup: expandedGroup,
1601
+ onToggleGroup: onToggleGroup,
1602
+ ),
1603
+ ),
1604
+ ),
1605
+ Padding(
1606
+ padding: const EdgeInsets.all(8),
1607
+ child: Row(
1608
+ children: <Widget>[
1609
+ const Spacer(),
1610
+ _ProfileSettingsButton(
1611
+ controller: controller,
1612
+ onTap: () {
1613
+ Navigator.of(context).pop();
1614
+ controller.setSelectedSection(AppSection.accountSettings);
1615
+ },
1616
+ ),
1617
+ const SizedBox(width: 8),
1618
+ _SidebarIconButton(
1619
+ tooltip: 'Logout',
1620
+ icon: Icons.logout,
1621
+ onTap: () {
1622
+ Navigator.of(context).pop();
1623
+ controller.logout();
1624
+ },
1625
+ ),
1626
+ ],
1627
+ ),
1628
+ ),
1629
+ ],
1630
+ ),
1631
+ ),
1632
+ );
1633
+ }
1634
+ }
1635
+
1636
+ class _SectionBody extends StatelessWidget {
1637
+ const _SectionBody({required this.controller});
1638
+
1639
+ final NeoAgentController controller;
1640
+
1641
+ @override
1642
+ Widget build(BuildContext context) {
1643
+ switch (controller.selectedSection) {
1644
+ case AppSection.chat:
1645
+ return ChatPanel(controller: controller);
1646
+ case AppSection.voiceAssistant:
1647
+ return VoiceAssistantPanel(controller: controller);
1648
+ case AppSection.devices:
1649
+ return DevicesPanel(controller: controller);
1650
+ case AppSection.recordings:
1651
+ return RecordingsPanel(controller: controller);
1652
+ case AppSection.messaging:
1653
+ return MessagingPanel(controller: controller);
1654
+ case AppSection.runs:
1655
+ return RunsPanel(controller: controller);
1656
+ case AppSection.settings:
1657
+ return SettingsPanel(controller: controller);
1658
+ case AppSection.accountSettings:
1659
+ return AccountSettingsPanel(controller: controller);
1660
+ case AppSection.logs:
1661
+ return LogsPanel(controller: controller);
1662
+ case AppSection.skills:
1663
+ return SkillsPanel(controller: controller);
1664
+ case AppSection.agents:
1665
+ return AgentsPanel(controller: controller);
1666
+ case AppSection.integrations:
1667
+ return IntegrationsPanel(controller: controller);
1668
+ case AppSection.memory:
1669
+ return MemoryPanel(controller: controller);
1670
+ case AppSection.tasks:
1671
+ return TasksPanel(controller: controller);
1672
+ case AppSection.widgets:
1673
+ return WidgetsPanel(controller: controller);
1674
+ case AppSection.mcp:
1675
+ return McpPanel(controller: controller);
1676
+ case AppSection.health:
1677
+ return controller.showHealthSection
1678
+ ? HealthPanel(controller: controller)
1679
+ : ChatPanel(controller: controller);
1680
+ }
1681
+ }
1682
+ }