neoagent 2.3.1-beta.4 → 2.3.1-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/.env.example +45 -0
  2. package/docs/capabilities.md +2 -2
  3. package/docs/configuration.md +12 -5
  4. package/docs/hardware.md +1 -1
  5. package/docs/integrations.md +2 -3
  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 +99 -0
  66. package/flutter_app/lib/main_account_settings.dart +1250 -0
  67. package/flutter_app/lib/main_admin.dart +886 -0
  68. package/flutter_app/lib/main_app_shell.dart +1682 -0
  69. package/flutter_app/lib/main_chat.dart +3352 -0
  70. package/flutter_app/lib/main_controller.dart +6781 -0
  71. package/flutter_app/lib/main_devices.dart +2301 -0
  72. package/flutter_app/lib/main_integrations.dart +1129 -0
  73. package/flutter_app/lib/main_launcher.dart +959 -0
  74. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  75. package/flutter_app/lib/main_models.dart +3546 -0
  76. package/flutter_app/lib/main_navigation.dart +193 -0
  77. package/flutter_app/lib/main_operations.dart +4851 -0
  78. package/flutter_app/lib/main_recordings.dart +870 -0
  79. package/flutter_app/lib/main_runtime.dart +806 -0
  80. package/flutter_app/lib/main_settings.dart +2024 -0
  81. package/flutter_app/lib/main_shared.dart +2861 -0
  82. package/flutter_app/lib/main_theme.dart +204 -0
  83. package/flutter_app/lib/main_voice_assistant.dart +957 -0
  84. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  85. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  86. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  87. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  88. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  89. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  90. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  91. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  92. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  93. package/flutter_app/lib/src/backend_client.dart +1833 -0
  94. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  95. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  96. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  97. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  98. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  99. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  100. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  101. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  102. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  103. package/flutter_app/lib/src/health_bridge.dart +136 -0
  104. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  105. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  106. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  107. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  108. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  109. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  110. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  111. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  112. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  113. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  114. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  115. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  116. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  117. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  118. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  119. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  120. package/flutter_app/lib/src/theme/palette.dart +81 -0
  121. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  122. package/flutter_app/linux/CMakeLists.txt +128 -0
  123. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  124. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  125. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  126. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  127. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  128. package/flutter_app/linux/runner/main.cc +6 -0
  129. package/flutter_app/linux/runner/my_application.cc +144 -0
  130. package/flutter_app/linux/runner/my_application.h +18 -0
  131. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  132. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  133. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  134. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  135. package/flutter_app/macos/Podfile +42 -0
  136. package/flutter_app/macos/Podfile.lock +87 -0
  137. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  138. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  139. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  140. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  141. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  142. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  143. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  144. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  145. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  146. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  147. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  148. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  149. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  150. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  151. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  152. package/flutter_app/macos/Runner/Info.plist +36 -0
  153. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  154. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  155. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  156. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  157. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  158. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  159. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  160. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  161. package/flutter_app/patch_strings.py +12 -0
  162. package/flutter_app/pubspec.lock +1088 -0
  163. package/flutter_app/pubspec.yaml +53 -0
  164. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  165. package/flutter_app/test/recording_payloads_test.dart +53 -0
  166. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  167. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  168. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  169. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  170. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  171. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  172. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  173. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  174. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  175. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  176. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  177. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  178. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  179. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  180. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  181. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  182. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  183. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  184. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  185. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  186. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  187. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  188. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  189. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  190. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  191. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  192. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  193. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  194. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  195. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  196. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  197. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  198. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  199. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  200. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  201. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  202. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  203. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  204. package/flutter_app/web/favicon.png +0 -0
  205. package/flutter_app/web/favicon.svg +12 -0
  206. package/flutter_app/web/icons/Icon-192.png +0 -0
  207. package/flutter_app/web/icons/Icon-512.png +0 -0
  208. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  209. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  210. package/flutter_app/web/index.html +39 -0
  211. package/flutter_app/web/manifest.json +35 -0
  212. package/flutter_app/windows/CMakeLists.txt +108 -0
  213. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  214. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  215. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  216. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  217. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  218. package/flutter_app/windows/runner/Runner.rc +121 -0
  219. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  220. package/flutter_app/windows/runner/flutter_window.h +37 -0
  221. package/flutter_app/windows/runner/main.cpp +53 -0
  222. package/flutter_app/windows/runner/resource.h +16 -0
  223. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  224. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  225. package/flutter_app/windows/runner/utils.cpp +65 -0
  226. package/flutter_app/windows/runner/utils.h +19 -0
  227. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  228. package/flutter_app/windows/runner/win32_window.h +102 -0
  229. package/lib/install_helpers.js +31 -0
  230. package/lib/manager.js +227 -6
  231. package/package.json +3 -1
  232. package/server/db/database.js +110 -0
  233. package/server/http/middleware.js +55 -2
  234. package/server/http/routes.js +1 -0
  235. package/server/index.js +3 -0
  236. package/server/public/.last_build_id +1 -1
  237. package/server/public/assets/NOTICES +1 -1
  238. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  239. package/server/public/canvaskit/wimp.wasm +0 -0
  240. package/server/public/flutter_bootstrap.js +2 -2
  241. package/server/public/main.dart.js +74324 -73132
  242. package/server/routes/integrations.js +108 -1
  243. package/server/routes/memory.js +11 -2
  244. package/server/{http/routes → routes}/screenHistory.js +2 -2
  245. package/server/routes/settings.js +75 -2
  246. package/server/{http/routes → routes}/triggers.js +2 -2
  247. package/server/routes/wearable.js +67 -0
  248. package/server/services/ai/models.js +30 -0
  249. package/server/services/ai/providers/githubCopilot.js +97 -0
  250. package/server/services/ai/providers/openai.js +2 -1
  251. package/server/services/ai/providers/openaiCodex.js +31 -0
  252. package/server/services/ai/settings.js +20 -0
  253. package/server/services/ai/toolSelector.js +14 -1
  254. package/server/services/ai/tools.js +77 -4
  255. package/server/services/desktop/screenRecorder.js +65 -9
  256. package/server/services/integrations/env.js +5 -0
  257. package/server/services/integrations/figma/provider.js +1 -0
  258. package/server/services/integrations/github/common.js +106 -0
  259. package/server/services/integrations/github/provider.js +499 -0
  260. package/server/services/integrations/github/repos.js +1124 -0
  261. package/server/services/integrations/google/provider.js +1 -0
  262. package/server/services/integrations/home_assistant/provider.js +325 -26
  263. package/server/services/integrations/manager.js +88 -12
  264. package/server/services/integrations/microsoft/provider.js +1 -0
  265. package/server/services/integrations/oauth_provider.js +25 -8
  266. package/server/services/integrations/provider_config_store.js +85 -0
  267. package/server/services/integrations/registry.js +4 -0
  268. package/server/services/integrations/spotify/provider.js +1 -0
  269. package/server/services/integrations/trello/provider.js +842 -0
  270. package/server/services/manager.js +46 -1
  271. package/server/services/mcp/client.js +120 -23
  272. package/server/services/memory/manager.js +39 -2
  273. package/server/services/messaging/access_policy.js +10 -0
  274. package/server/services/messaging/manager.js +49 -0
  275. package/server/services/messaging/meshtastic.js +260 -0
  276. package/server/services/messaging/meshtastic_env.js +100 -0
  277. package/server/services/messaging/meshtastic_protocol.js +476 -0
  278. package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
  279. package/server/services/tasks/runtime.js +1 -1
  280. package/server/services/voice/openaiClient.js +4 -1
  281. package/server/services/voice/openaiSpeech.js +6 -1
  282. package/server/services/voice/providers.js +52 -12
  283. package/server/services/voice/runtimeManager.js +136 -19
  284. package/server/services/voice/turnRunner.js +29 -9
  285. package/server/services/wearable/firmware_manifest.js +370 -0
  286. package/server/services/wearable/gateway.js +350 -0
  287. package/server/services/wearable/protocol.js +45 -0
  288. package/server/services/wearable/service.js +244 -0
  289. package/server/utils/local_secrets.js +56 -0
  290. package/server/utils/logger.js +37 -9
@@ -0,0 +1,1250 @@
1
+ part of 'main.dart';
2
+
3
+ class _PasswordStrengthInfo {
4
+ const _PasswordStrengthInfo({
5
+ required this.score,
6
+ required this.label,
7
+ required this.message,
8
+ required this.color,
9
+ });
10
+
11
+ final int score;
12
+ final String label;
13
+ final String message;
14
+ final Color color;
15
+ }
16
+
17
+ _PasswordStrengthInfo _passwordStrengthInfo({
18
+ required String password,
19
+ String username = '',
20
+ String email = '',
21
+ }) {
22
+ final value = password.trim();
23
+ if (value.isEmpty) {
24
+ return _PasswordStrengthInfo(
25
+ score: 0,
26
+ label: 'Empty',
27
+ message: 'Use 8+ characters. Longer passphrases work well.',
28
+ color: _borderLight,
29
+ );
30
+ }
31
+ final lower = RegExp(r'[a-z]').hasMatch(value);
32
+ final upper = RegExp(r'[A-Z]').hasMatch(value);
33
+ final digits = RegExp(r'[0-9]').hasMatch(value);
34
+ final symbols = RegExp(r'[^A-Za-z0-9]').hasMatch(value);
35
+ final variety = <bool>[
36
+ lower,
37
+ upper,
38
+ digits,
39
+ symbols,
40
+ ].where((item) => item).length;
41
+ final normalized = value.toLowerCase();
42
+ final userHints = <String>{
43
+ username.trim().toLowerCase(),
44
+ email.trim().toLowerCase(),
45
+ email.trim().toLowerCase().split('@').first,
46
+ }.where((item) => item.length >= 3);
47
+ final containsUserInfo = userHints.any(normalized.contains);
48
+ final obviousPattern =
49
+ RegExp(r'(.)\1\1').hasMatch(value) ||
50
+ normalized.contains('password') ||
51
+ normalized.contains('1234') ||
52
+ normalized.contains('qwerty');
53
+
54
+ var score = 0;
55
+ if (value.length >= 8) score += 1;
56
+ if (value.length >= 12) score += 1;
57
+ if (variety >= 3) score += 1;
58
+ if (variety == 4 || value.length >= 16) score += 1;
59
+ if (containsUserInfo || obviousPattern) score -= 1;
60
+ score = score.clamp(0, 4);
61
+
62
+ if (value.length < 8) {
63
+ return _PasswordStrengthInfo(
64
+ score: 1,
65
+ label: 'Weak',
66
+ message: 'Use at least 8 characters.',
67
+ color: _danger,
68
+ );
69
+ }
70
+ if (containsUserInfo) {
71
+ return _PasswordStrengthInfo(
72
+ score: 2,
73
+ label: 'Fair',
74
+ message: 'Do not include your username or email.',
75
+ color: _warning,
76
+ );
77
+ }
78
+ if (obviousPattern) {
79
+ return _PasswordStrengthInfo(
80
+ score: 2,
81
+ label: 'Fair',
82
+ message: 'Avoid repeated characters and obvious sequences.',
83
+ color: _warning,
84
+ );
85
+ }
86
+ if (score >= 4) {
87
+ return _PasswordStrengthInfo(
88
+ score: 4,
89
+ label: 'Strong',
90
+ message: 'Strong password.',
91
+ color: _success,
92
+ );
93
+ }
94
+ if (score >= 3) {
95
+ return _PasswordStrengthInfo(
96
+ score: 3,
97
+ label: 'Good',
98
+ message: 'Good password. A little more length makes it stronger.',
99
+ color: _success,
100
+ );
101
+ }
102
+ return _PasswordStrengthInfo(
103
+ score: 2,
104
+ label: 'Fair',
105
+ message: 'Add more length or another character type.',
106
+ color: _warning,
107
+ );
108
+ }
109
+
110
+ class _PasswordStrengthIndicator extends StatelessWidget {
111
+ const _PasswordStrengthIndicator({required this.info});
112
+
113
+ final _PasswordStrengthInfo info;
114
+
115
+ @override
116
+ Widget build(BuildContext context) {
117
+ return Column(
118
+ crossAxisAlignment: CrossAxisAlignment.start,
119
+ children: <Widget>[
120
+ Row(
121
+ children: <Widget>[
122
+ Text(
123
+ 'Password strength: ${info.label}',
124
+ style: TextStyle(color: info.color, fontWeight: FontWeight.w600),
125
+ ),
126
+ const SizedBox(width: 10),
127
+ Expanded(
128
+ child: ClipRRect(
129
+ borderRadius: BorderRadius.circular(999),
130
+ child: LinearProgressIndicator(
131
+ minHeight: 8,
132
+ value: info.score / 4,
133
+ backgroundColor: _borderLight,
134
+ valueColor: AlwaysStoppedAnimation<Color>(info.color),
135
+ ),
136
+ ),
137
+ ),
138
+ ],
139
+ ),
140
+ const SizedBox(height: 6),
141
+ Text(
142
+ info.message,
143
+ style: TextStyle(color: _textSecondary, fontSize: 12, height: 1.35),
144
+ ),
145
+ ],
146
+ );
147
+ }
148
+ }
149
+
150
+ enum AccountSettingsTab { account, security }
151
+
152
+ class AccountSettingsPanel extends StatefulWidget {
153
+ const AccountSettingsPanel({super.key, required this.controller});
154
+
155
+ final NeoAgentController controller;
156
+
157
+ @override
158
+ State<AccountSettingsPanel> createState() => _AccountSettingsPanelState();
159
+ }
160
+
161
+ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
162
+ AccountSettingsTab _selectedTab = AccountSettingsTab.account;
163
+ late final TextEditingController _emailController;
164
+ late final TextEditingController _emailPasswordController;
165
+ late final TextEditingController _setupPasswordController;
166
+ late final TextEditingController _setupCodeController;
167
+ late final TextEditingController _disablePasswordController;
168
+ late final TextEditingController _disableCodeController;
169
+ late final TextEditingController _currentPasswordController;
170
+ late final TextEditingController _newPasswordController;
171
+ late final TextEditingController _confirmNewPasswordController;
172
+ Map<String, dynamic>? _pendingSetup;
173
+ List<String> _recoveryCodes = const <String>[];
174
+ String? _emailSuccessMessage;
175
+ String? _emailInlineError;
176
+ String? _passwordSuccessMessage;
177
+ String? _passwordInlineError;
178
+
179
+ @override
180
+ void initState() {
181
+ super.initState();
182
+ _emailController = TextEditingController(
183
+ text: widget.controller.user?['email']?.toString() ?? '',
184
+ );
185
+ _emailPasswordController = TextEditingController();
186
+ _setupPasswordController = TextEditingController();
187
+ _setupCodeController = TextEditingController();
188
+ _disablePasswordController = TextEditingController();
189
+ _disableCodeController = TextEditingController();
190
+ _currentPasswordController = TextEditingController();
191
+ _newPasswordController = TextEditingController();
192
+ _confirmNewPasswordController = TextEditingController();
193
+ unawaited(widget.controller.refreshAccountSettings());
194
+ }
195
+
196
+ @override
197
+ void didUpdateWidget(covariant AccountSettingsPanel oldWidget) {
198
+ super.didUpdateWidget(oldWidget);
199
+ final email = widget.controller.user?['email']?.toString() ?? '';
200
+ if (_emailController.text.isEmpty && email.isNotEmpty) {
201
+ _emailController.text = email;
202
+ }
203
+ }
204
+
205
+ @override
206
+ void dispose() {
207
+ _emailController.dispose();
208
+ _emailPasswordController.dispose();
209
+ _setupPasswordController.dispose();
210
+ _setupCodeController.dispose();
211
+ _disablePasswordController.dispose();
212
+ _disableCodeController.dispose();
213
+ _currentPasswordController.dispose();
214
+ _newPasswordController.dispose();
215
+ _confirmNewPasswordController.dispose();
216
+ super.dispose();
217
+ }
218
+
219
+ bool get _supportsQrLoginApproval =>
220
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
221
+
222
+ Future<void> _startQrLoginApproval() async {
223
+ final scanned = await showDialog<String>(
224
+ context: context,
225
+ barrierDismissible: true,
226
+ builder: (dialogContext) => const _QrLoginScannerDialog(),
227
+ );
228
+ if (!mounted || scanned == null || scanned.trim().isEmpty) {
229
+ return;
230
+ }
231
+
232
+ final payload = QrLoginScanPayload.tryParse(scanned);
233
+ if (payload == null) {
234
+ ScaffoldMessenger.of(context).showSnackBar(
235
+ const SnackBar(
236
+ content: Text('That QR code is not a NeoAgent login request.'),
237
+ ),
238
+ );
239
+ return;
240
+ }
241
+
242
+ final scannedBackend = widget.controller._normalizeBackendUrl(
243
+ payload.backendUrl,
244
+ );
245
+ final currentBackend = widget.controller._normalizeBackendUrl(
246
+ widget.controller.backendUrl,
247
+ );
248
+ if (scannedBackend != currentBackend) {
249
+ ScaffoldMessenger.of(context).showSnackBar(
250
+ SnackBar(
251
+ content: Text(
252
+ 'This code belongs to a different NeoAgent server: ${payload.backendUrl}',
253
+ ),
254
+ ),
255
+ );
256
+ return;
257
+ }
258
+
259
+ try {
260
+ final preview = await widget.controller.resolveQrLoginApproval(payload);
261
+ if (!mounted) return;
262
+ final approved = await showDialog<bool>(
263
+ context: context,
264
+ builder: (dialogContext) {
265
+ return _QrLoginApprovalDialog(
266
+ preview: preview,
267
+ busy: widget.controller.isApprovingQrLogin,
268
+ );
269
+ },
270
+ );
271
+ if (approved != true || !mounted) {
272
+ return;
273
+ }
274
+ await widget.controller.approveQrLogin(payload);
275
+ if (!mounted) return;
276
+ ScaffoldMessenger.of(context).showSnackBar(
277
+ SnackBar(
278
+ content: Text('Approved login for ${preview.requestedDevice.label}.'),
279
+ ),
280
+ );
281
+ } catch (_) {
282
+ if (!mounted) return;
283
+ final message =
284
+ widget.controller.errorMessage ?? 'Could not approve QR login.';
285
+ ScaffoldMessenger.of(
286
+ context,
287
+ ).showSnackBar(SnackBar(content: Text(message)));
288
+ }
289
+ }
290
+
291
+ @override
292
+ Widget build(BuildContext context) {
293
+ final compact = MediaQuery.sizeOf(context).width < 860;
294
+ return ListView(
295
+ padding: _pagePadding(context),
296
+ children: <Widget>[
297
+ _PageTitle(
298
+ title: 'Account settings',
299
+ subtitle:
300
+ 'Manage your account email, two-factor authentication, and active sessions.',
301
+ trailing: OutlinedButton.icon(
302
+ onPressed: widget.controller.isLoadingAccountSettings
303
+ ? null
304
+ : widget.controller.refreshAccountSettings,
305
+ icon: widget.controller.isLoadingAccountSettings
306
+ ? const SizedBox.square(
307
+ dimension: 16,
308
+ child: CircularProgressIndicator(strokeWidth: 2),
309
+ )
310
+ : Icon(Icons.refresh),
311
+ label: Text('Refresh'),
312
+ ),
313
+ ),
314
+ if (widget.controller.errorMessage != null) ...<Widget>[
315
+ _InlineError(message: widget.controller.errorMessage!),
316
+ const SizedBox(height: 16),
317
+ ],
318
+ if (compact)
319
+ _AccountSettingsTabs(
320
+ selected: _selectedTab,
321
+ onSelected: (value) => setState(() => _selectedTab = value),
322
+ )
323
+ else
324
+ const SizedBox.shrink(),
325
+ if (compact) const SizedBox(height: 16),
326
+ Card(
327
+ child: Padding(
328
+ padding: const EdgeInsets.all(20),
329
+ child: compact
330
+ ? _buildSelectedPanel()
331
+ : Row(
332
+ crossAxisAlignment: CrossAxisAlignment.start,
333
+ children: <Widget>[
334
+ SizedBox(
335
+ width: 220,
336
+ child: _AccountSettingsTabs(
337
+ selected: _selectedTab,
338
+ onSelected: (value) =>
339
+ setState(() => _selectedTab = value),
340
+ vertical: true,
341
+ ),
342
+ ),
343
+ const SizedBox(width: 24),
344
+ Expanded(child: _buildSelectedPanel()),
345
+ ],
346
+ ),
347
+ ),
348
+ ),
349
+ ],
350
+ );
351
+ }
352
+
353
+ Widget _buildSelectedPanel() {
354
+ switch (_selectedTab) {
355
+ case AccountSettingsTab.account:
356
+ return _buildAccountPanel();
357
+ case AccountSettingsTab.security:
358
+ return _buildSecurityPanel();
359
+ }
360
+ }
361
+
362
+ Widget _buildAccountPanel() {
363
+ final controller = widget.controller;
364
+ final username = controller.user?['username']?.toString() ?? 'Account';
365
+ final currentEmail =
366
+ controller.user?['email']?.toString() ?? 'No email linked';
367
+ final hasPassword = controller.user?['hasPassword'] == true;
368
+ final availableProviders = controller.authProviders
369
+ .where((provider) => provider.configured)
370
+ .toList();
371
+ final linkedProviderKeys = controller.linkedAuthProviders
372
+ .map((provider) => provider.provider)
373
+ .toSet();
374
+ final linkableProviders = availableProviders
375
+ .where((provider) => !linkedProviderKeys.contains(provider.id))
376
+ .toList();
377
+ return Column(
378
+ crossAxisAlignment: CrossAxisAlignment.start,
379
+ children: <Widget>[
380
+ const _SectionTitle('Account'),
381
+ const SizedBox(height: 12),
382
+ _MetaPill(label: username, icon: Icons.person_outline),
383
+ const SizedBox(height: 18),
384
+ Text('Current email: $currentEmail'),
385
+ const SizedBox(height: 16),
386
+ TextField(
387
+ controller: _emailController,
388
+ keyboardType: TextInputType.emailAddress,
389
+ decoration: const InputDecoration(labelText: 'Email'),
390
+ ),
391
+ const SizedBox(height: 12),
392
+ TextField(
393
+ controller: _emailPasswordController,
394
+ obscureText: true,
395
+ enabled: hasPassword,
396
+ decoration: InputDecoration(
397
+ labelText: 'Current password',
398
+ helperText: hasPassword
399
+ ? 'Required to add or change your account email.'
400
+ : 'Create a password first to change your account email.',
401
+ ),
402
+ ),
403
+ if (_emailInlineError != null) ...<Widget>[
404
+ const SizedBox(height: 10),
405
+ _InlineError(message: _emailInlineError!),
406
+ ],
407
+ if (_emailSuccessMessage != null) ...<Widget>[
408
+ const SizedBox(height: 10),
409
+ _InlineSuccess(message: _emailSuccessMessage!),
410
+ ],
411
+ const SizedBox(height: 14),
412
+ FilledButton.icon(
413
+ onPressed: controller.isSavingAccountSettings || !hasPassword
414
+ ? null
415
+ : () async {
416
+ setState(() {
417
+ _emailInlineError = null;
418
+ _emailSuccessMessage = null;
419
+ });
420
+ if (_emailPasswordController.text.trim().isEmpty) {
421
+ setState(() {
422
+ _emailInlineError =
423
+ 'Enter your current password to save email changes.';
424
+ });
425
+ return;
426
+ }
427
+ final trimmedEmail = _emailController.text.trim();
428
+ final saved = await controller.updateAccountEmail(
429
+ email: trimmedEmail,
430
+ currentPassword: _emailPasswordController.text,
431
+ );
432
+ if (saved && mounted) {
433
+ setState(() {
434
+ _emailPasswordController.clear();
435
+ _emailSuccessMessage =
436
+ 'Email saved. If confirmation is required, check the new address for a NeoAgent confirmation link.';
437
+ });
438
+ }
439
+ },
440
+ icon: controller.isSavingAccountSettings
441
+ ? const SizedBox.square(
442
+ dimension: 16,
443
+ child: CircularProgressIndicator(strokeWidth: 2),
444
+ )
445
+ : Icon(Icons.save_outlined),
446
+ label: Text('Save email'),
447
+ ),
448
+ const SizedBox(height: 28),
449
+ Row(
450
+ children: <Widget>[
451
+ const Expanded(child: _SectionTitle('Linked sign-in providers')),
452
+ if (controller.linkedAuthProviders.isNotEmpty)
453
+ Text(
454
+ '${controller.linkedAuthProviders.length} linked',
455
+ style: TextStyle(color: _textSecondary),
456
+ ),
457
+ ],
458
+ ),
459
+ const SizedBox(height: 12),
460
+ if (controller.linkedAuthProviders.isEmpty)
461
+ Text(
462
+ 'No external sign-in providers linked.',
463
+ style: TextStyle(color: _textSecondary),
464
+ )
465
+ else
466
+ ...controller.linkedAuthProviders.map(
467
+ (provider) => Card(
468
+ margin: const EdgeInsets.only(bottom: 12),
469
+ child: ListTile(
470
+ leading: provider.icon == 'google'
471
+ ? const CircleAvatar(
472
+ backgroundColor: Color(0x1A4285F4),
473
+ child: Text(
474
+ 'G',
475
+ style: TextStyle(
476
+ color: Color(0xFF4285F4),
477
+ fontWeight: FontWeight.w700,
478
+ ),
479
+ ),
480
+ )
481
+ : const CircleAvatar(child: Icon(Icons.link)),
482
+ title: Text(provider.label),
483
+ subtitle: Text(
484
+ provider.email.isNotEmpty
485
+ ? '${provider.email}\nLast used: ${provider.lastUsedLabel}'
486
+ : 'Last used: ${provider.lastUsedLabel}',
487
+ ),
488
+ isThreeLine: provider.email.isNotEmpty,
489
+ trailing: TextButton(
490
+ onPressed:
491
+ controller.isSavingAccountSettings || !provider.canUnlink
492
+ ? null
493
+ : () => controller.unlinkAccountProvider(provider.id),
494
+ child: const Text('Unlink'),
495
+ ),
496
+ ),
497
+ ),
498
+ ),
499
+ if (linkableProviders.isNotEmpty) ...<Widget>[
500
+ const SizedBox(height: 8),
501
+ Wrap(
502
+ spacing: 10,
503
+ runSpacing: 10,
504
+ children: linkableProviders
505
+ .map(
506
+ (provider) => OutlinedButton.icon(
507
+ onPressed: controller.isSavingAccountSettings
508
+ ? null
509
+ : () => controller.linkAccountProvider(provider.id),
510
+ icon: provider.icon == 'google'
511
+ ? const Text(
512
+ 'G',
513
+ style: TextStyle(
514
+ fontSize: 18,
515
+ fontWeight: FontWeight.w700,
516
+ color: Color(0xFF4285F4),
517
+ ),
518
+ )
519
+ : const Icon(Icons.link),
520
+ label: Text('Link ${provider.label}'),
521
+ ),
522
+ )
523
+ .toList(),
524
+ ),
525
+ ],
526
+ ],
527
+ );
528
+ }
529
+
530
+ Widget _buildSecurityPanel() {
531
+ final controller = widget.controller;
532
+ final twoFactorEnabled = controller.accountTwoFactor['enabled'] == true;
533
+ final recoveryCount = _asInt(
534
+ controller.accountTwoFactor['recoveryCodesRemaining'],
535
+ );
536
+ return Column(
537
+ crossAxisAlignment: CrossAxisAlignment.start,
538
+ children: <Widget>[
539
+ if (_supportsQrLoginApproval) ...<Widget>[
540
+ Row(
541
+ children: <Widget>[
542
+ const Expanded(child: _SectionTitle('Approve QR login')),
543
+ _StatusPill(label: 'Android only', color: _accent),
544
+ ],
545
+ ),
546
+ const SizedBox(height: 12),
547
+ Text(
548
+ 'Scan QR login requests from signed-out devices and approve them from this authenticated mobile session.',
549
+ style: TextStyle(color: _textSecondary, height: 1.4),
550
+ ),
551
+ const SizedBox(height: 14),
552
+ Container(
553
+ width: double.infinity,
554
+ padding: const EdgeInsets.all(18),
555
+ decoration: BoxDecoration(
556
+ gradient: LinearGradient(
557
+ begin: Alignment.topLeft,
558
+ end: Alignment.bottomRight,
559
+ colors: <Color>[
560
+ _accent.withValues(alpha: 0.16),
561
+ _success.withValues(alpha: 0.10),
562
+ ],
563
+ ),
564
+ borderRadius: BorderRadius.circular(18),
565
+ border: Border.all(color: _borderLight),
566
+ ),
567
+ child: Wrap(
568
+ spacing: 12,
569
+ runSpacing: 12,
570
+ crossAxisAlignment: WrapCrossAlignment.center,
571
+ children: <Widget>[
572
+ FilledButton.icon(
573
+ onPressed: controller.isApprovingQrLogin
574
+ ? null
575
+ : _startQrLoginApproval,
576
+ icon: controller.isApprovingQrLogin
577
+ ? const SizedBox.square(
578
+ dimension: 16,
579
+ child: CircularProgressIndicator(strokeWidth: 2),
580
+ )
581
+ : const Icon(Icons.camera_alt_outlined),
582
+ label: const Text('Scan login QR'),
583
+ ),
584
+ ],
585
+ ),
586
+ ),
587
+ const SizedBox(height: 24),
588
+ ],
589
+ _buildPasswordPanel(),
590
+ const SizedBox(height: 24),
591
+ Row(
592
+ children: <Widget>[
593
+ Expanded(child: _SectionTitle('Two-factor authentication')),
594
+ _StatusPill(
595
+ label: twoFactorEnabled ? 'Enabled' : 'Disabled',
596
+ color: twoFactorEnabled ? _success : _warning,
597
+ ),
598
+ ],
599
+ ),
600
+ const SizedBox(height: 12),
601
+ Text(
602
+ twoFactorEnabled
603
+ ? '$recoveryCount recovery codes are still available.'
604
+ : 'Use an authenticator app such as Authy, 1Password, or Google Authenticator.',
605
+ style: TextStyle(color: _textSecondary, height: 1.4),
606
+ ),
607
+ const SizedBox(height: 16),
608
+ if (!twoFactorEnabled) _buildEnableTwoFactorPanel(),
609
+ if (twoFactorEnabled) _buildDisableTwoFactorPanel(),
610
+ if (_recoveryCodes.isNotEmpty) ...<Widget>[
611
+ const SizedBox(height: 16),
612
+ _RecoveryCodesCard(codes: _recoveryCodes),
613
+ ],
614
+ const SizedBox(height: 24),
615
+ Row(
616
+ children: <Widget>[
617
+ Expanded(child: _SectionTitle('Active sessions')),
618
+ Text(
619
+ '${controller.accountSessions.length} active',
620
+ style: TextStyle(color: _textSecondary),
621
+ ),
622
+ ],
623
+ ),
624
+ const SizedBox(height: 12),
625
+ if (controller.accountSessions.isEmpty)
626
+ Text(
627
+ 'No active sessions found.',
628
+ style: TextStyle(color: _textSecondary),
629
+ )
630
+ else
631
+ ...controller.accountSessions.map(
632
+ (session) => _AccountSessionCard(
633
+ session: session,
634
+ busy: controller.isRevokingSession,
635
+ onRevoke: session.current
636
+ ? null
637
+ : () => controller.revokeAccountSession(session.id),
638
+ ),
639
+ ),
640
+ ],
641
+ );
642
+ }
643
+
644
+ Widget _buildPasswordPanel() {
645
+ final controller = widget.controller;
646
+ final hasPassword = controller.user?['hasPassword'] == true;
647
+ final strength = _passwordStrengthInfo(
648
+ password: _newPasswordController.text,
649
+ username: controller.user?['username']?.toString() ?? '',
650
+ email: controller.user?['email']?.toString() ?? '',
651
+ );
652
+ return Column(
653
+ crossAxisAlignment: CrossAxisAlignment.start,
654
+ children: <Widget>[
655
+ const _SectionTitle('Password'),
656
+ const SizedBox(height: 12),
657
+ if (hasPassword) ...<Widget>[
658
+ TextField(
659
+ controller: _currentPasswordController,
660
+ obscureText: true,
661
+ decoration: const InputDecoration(labelText: 'Current password'),
662
+ ),
663
+ const SizedBox(height: 12),
664
+ ] else ...<Widget>[
665
+ Text(
666
+ 'No local password is set yet. Create one to enable username/password sign-in.',
667
+ style: TextStyle(color: _textSecondary, height: 1.4),
668
+ ),
669
+ const SizedBox(height: 12),
670
+ ],
671
+ TextField(
672
+ controller: _newPasswordController,
673
+ onChanged: (_) => setState(() {}),
674
+ obscureText: true,
675
+ decoration: InputDecoration(
676
+ labelText: hasPassword ? 'New password' : 'Create password',
677
+ ),
678
+ ),
679
+ const SizedBox(height: 10),
680
+ _PasswordStrengthIndicator(info: strength),
681
+ const SizedBox(height: 12),
682
+ TextField(
683
+ controller: _confirmNewPasswordController,
684
+ obscureText: true,
685
+ decoration: InputDecoration(
686
+ labelText: hasPassword
687
+ ? 'Confirm new password'
688
+ : 'Confirm password',
689
+ ),
690
+ ),
691
+ if (_passwordInlineError != null) ...<Widget>[
692
+ const SizedBox(height: 10),
693
+ _InlineError(message: _passwordInlineError!),
694
+ ],
695
+ if (_passwordSuccessMessage != null) ...<Widget>[
696
+ const SizedBox(height: 10),
697
+ _InlineSuccess(message: _passwordSuccessMessage!),
698
+ ],
699
+ const SizedBox(height: 14),
700
+ FilledButton.icon(
701
+ onPressed: controller.isSavingAccountSettings
702
+ ? null
703
+ : () async {
704
+ setState(() {
705
+ _passwordInlineError = null;
706
+ _passwordSuccessMessage = null;
707
+ });
708
+ if (hasPassword && _currentPasswordController.text.isEmpty) {
709
+ setState(() {
710
+ _passwordInlineError =
711
+ 'Enter your current password to change it.';
712
+ });
713
+ return;
714
+ }
715
+ if (_newPasswordController.text.length < 8) {
716
+ setState(() {
717
+ _passwordInlineError =
718
+ 'Use a new password with at least 8 characters.';
719
+ });
720
+ return;
721
+ }
722
+ if (_newPasswordController.text !=
723
+ _confirmNewPasswordController.text) {
724
+ setState(() {
725
+ _passwordInlineError = 'New passwords do not match.';
726
+ });
727
+ return;
728
+ }
729
+ final saved = await controller.updateAccountPassword(
730
+ currentPassword: _currentPasswordController.text,
731
+ newPassword: _newPasswordController.text,
732
+ );
733
+ if (saved && mounted) {
734
+ setState(() {
735
+ _currentPasswordController.clear();
736
+ _newPasswordController.clear();
737
+ _confirmNewPasswordController.clear();
738
+ _passwordSuccessMessage = hasPassword
739
+ ? 'Password changed.'
740
+ : 'Password created.';
741
+ });
742
+ }
743
+ },
744
+ icon: controller.isSavingAccountSettings
745
+ ? const SizedBox.square(
746
+ dimension: 16,
747
+ child: CircularProgressIndicator(strokeWidth: 2),
748
+ )
749
+ : Icon(Icons.password_outlined),
750
+ label: Text(hasPassword ? 'Change password' : 'Create password'),
751
+ ),
752
+ ],
753
+ );
754
+ }
755
+
756
+ Widget _buildEnableTwoFactorPanel() {
757
+ final setupUrl = _pendingSetup?['otpauthUrl']?.toString() ?? '';
758
+ final manualKey = _pendingSetup?['manualKey']?.toString() ?? '';
759
+ return Column(
760
+ crossAxisAlignment: CrossAxisAlignment.start,
761
+ children: <Widget>[
762
+ if (_pendingSetup == null) ...<Widget>[
763
+ TextField(
764
+ controller: _setupPasswordController,
765
+ obscureText: true,
766
+ decoration: const InputDecoration(labelText: 'Current password'),
767
+ ),
768
+ const SizedBox(height: 12),
769
+ FilledButton.icon(
770
+ onPressed: widget.controller.isConfiguringTwoFactor
771
+ ? null
772
+ : () async {
773
+ final setup = await widget.controller.beginTwoFactorSetup(
774
+ _setupPasswordController.text,
775
+ );
776
+ if (setup != null && mounted) {
777
+ setState(() => _pendingSetup = setup);
778
+ }
779
+ },
780
+ icon: Icon(Icons.qr_code_2_outlined),
781
+ label: Text('Start setup'),
782
+ ),
783
+ ] else ...<Widget>[
784
+ Center(
785
+ child: Container(
786
+ color: Colors.white,
787
+ padding: const EdgeInsets.all(12),
788
+ child: QrImageView(
789
+ data: setupUrl,
790
+ version: QrVersions.auto,
791
+ size: 220,
792
+ ),
793
+ ),
794
+ ),
795
+ const SizedBox(height: 12),
796
+ SelectableText(manualKey, style: TextStyle(color: _textSecondary)),
797
+ const SizedBox(height: 12),
798
+ TextField(
799
+ controller: _setupCodeController,
800
+ keyboardType: TextInputType.number,
801
+ decoration: const InputDecoration(labelText: 'Authenticator code'),
802
+ ),
803
+ const SizedBox(height: 12),
804
+ FilledButton.icon(
805
+ onPressed: widget.controller.isConfiguringTwoFactor
806
+ ? null
807
+ : () async {
808
+ final codes = await widget.controller.enableTwoFactor(
809
+ _setupCodeController.text,
810
+ );
811
+ if (codes.isNotEmpty && mounted) {
812
+ setState(() {
813
+ _recoveryCodes = codes;
814
+ _pendingSetup = null;
815
+ _setupPasswordController.clear();
816
+ _setupCodeController.clear();
817
+ });
818
+ }
819
+ },
820
+ icon: Icon(Icons.verified_user_outlined),
821
+ label: Text('Enable 2FA'),
822
+ ),
823
+ ],
824
+ ],
825
+ );
826
+ }
827
+
828
+ Widget _buildDisableTwoFactorPanel() {
829
+ return Column(
830
+ crossAxisAlignment: CrossAxisAlignment.start,
831
+ children: <Widget>[
832
+ TextField(
833
+ controller: _disablePasswordController,
834
+ obscureText: true,
835
+ decoration: const InputDecoration(labelText: 'Current password'),
836
+ ),
837
+ const SizedBox(height: 12),
838
+ TextField(
839
+ controller: _disableCodeController,
840
+ decoration: const InputDecoration(labelText: '2FA or recovery code'),
841
+ ),
842
+ const SizedBox(height: 12),
843
+ Wrap(
844
+ spacing: 10,
845
+ runSpacing: 10,
846
+ children: <Widget>[
847
+ FilledButton.icon(
848
+ onPressed: widget.controller.isConfiguringTwoFactor
849
+ ? null
850
+ : () => widget.controller.disableTwoFactor(
851
+ currentPassword: _disablePasswordController.text,
852
+ code: _disableCodeController.text,
853
+ ),
854
+ icon: Icon(Icons.lock_open_outlined),
855
+ label: Text('Disable 2FA'),
856
+ ),
857
+ OutlinedButton.icon(
858
+ onPressed: widget.controller.isConfiguringTwoFactor
859
+ ? null
860
+ : () async {
861
+ final codes = await widget.controller
862
+ .regenerateRecoveryCodes(
863
+ currentPassword: _disablePasswordController.text,
864
+ code: _disableCodeController.text,
865
+ );
866
+ if (codes.isNotEmpty && mounted) {
867
+ setState(() => _recoveryCodes = codes);
868
+ }
869
+ },
870
+ icon: Icon(Icons.password_outlined),
871
+ label: Text('New recovery codes'),
872
+ ),
873
+ ],
874
+ ),
875
+ ],
876
+ );
877
+ }
878
+ }
879
+
880
+ class _AccountSettingsTabs extends StatelessWidget {
881
+ const _AccountSettingsTabs({
882
+ required this.selected,
883
+ required this.onSelected,
884
+ this.vertical = false,
885
+ });
886
+
887
+ final AccountSettingsTab selected;
888
+ final ValueChanged<AccountSettingsTab> onSelected;
889
+ final bool vertical;
890
+
891
+ @override
892
+ Widget build(BuildContext context) {
893
+ final buttons = <Widget>[
894
+ _tabButton(AccountSettingsTab.account, Icons.person_outline, 'Account'),
895
+ _tabButton(
896
+ AccountSettingsTab.security,
897
+ Icons.security_outlined,
898
+ 'Security',
899
+ ),
900
+ ];
901
+ return vertical
902
+ ? Column(children: buttons)
903
+ : Wrap(spacing: 8, runSpacing: 8, children: buttons);
904
+ }
905
+
906
+ Widget _tabButton(AccountSettingsTab tab, IconData icon, String label) {
907
+ return Padding(
908
+ padding: EdgeInsets.only(bottom: vertical ? 8 : 0),
909
+ child: _SidebarButton(
910
+ label: label,
911
+ icon: icon,
912
+ active: selected == tab,
913
+ onTap: () => onSelected(tab),
914
+ ),
915
+ );
916
+ }
917
+ }
918
+
919
+ class _RecoveryCodesCard extends StatelessWidget {
920
+ const _RecoveryCodesCard({required this.codes});
921
+
922
+ final List<String> codes;
923
+
924
+ @override
925
+ Widget build(BuildContext context) {
926
+ return Container(
927
+ width: double.infinity,
928
+ padding: const EdgeInsets.all(16),
929
+ decoration: BoxDecoration(
930
+ color: _warning.withValues(alpha: 0.08),
931
+ borderRadius: BorderRadius.circular(12),
932
+ border: Border.all(color: _warning.withValues(alpha: 0.35)),
933
+ ),
934
+ child: Column(
935
+ crossAxisAlignment: CrossAxisAlignment.start,
936
+ children: <Widget>[
937
+ Text(
938
+ 'Save these recovery codes now. They will not be shown again.',
939
+ style: TextStyle(fontWeight: FontWeight.w700),
940
+ ),
941
+ const SizedBox(height: 10),
942
+ Wrap(
943
+ spacing: 10,
944
+ runSpacing: 10,
945
+ children: codes
946
+ .map(
947
+ (code) => SelectableText(
948
+ code,
949
+ style: TextStyle(fontFamily: 'monospace'),
950
+ ),
951
+ )
952
+ .toList(),
953
+ ),
954
+ const SizedBox(height: 12),
955
+ OutlinedButton.icon(
956
+ onPressed: () =>
957
+ Clipboard.setData(ClipboardData(text: codes.join('\n'))),
958
+ icon: Icon(Icons.copy_outlined),
959
+ label: Text('Copy codes'),
960
+ ),
961
+ ],
962
+ ),
963
+ );
964
+ }
965
+ }
966
+
967
+ class _AccountSessionCard extends StatelessWidget {
968
+ const _AccountSessionCard({
969
+ required this.session,
970
+ required this.busy,
971
+ required this.onRevoke,
972
+ });
973
+
974
+ final AccountSessionItem session;
975
+ final bool busy;
976
+ final VoidCallback? onRevoke;
977
+
978
+ @override
979
+ Widget build(BuildContext context) {
980
+ return Container(
981
+ margin: const EdgeInsets.only(bottom: 10),
982
+ padding: const EdgeInsets.all(14),
983
+ decoration: BoxDecoration(
984
+ color: _bgSecondary,
985
+ borderRadius: BorderRadius.circular(12),
986
+ border: Border.all(color: _border),
987
+ ),
988
+ child: Row(
989
+ crossAxisAlignment: CrossAxisAlignment.start,
990
+ children: <Widget>[
991
+ Icon(
992
+ session.deviceIcon,
993
+ color: session.current ? _success : _textSecondary,
994
+ ),
995
+ const SizedBox(width: 12),
996
+ Expanded(
997
+ child: Column(
998
+ crossAxisAlignment: CrossAxisAlignment.start,
999
+ children: <Widget>[
1000
+ Text(
1001
+ session.current
1002
+ ? '${session.clientLabel} · Current session'
1003
+ : session.clientLabel,
1004
+ style: TextStyle(fontWeight: FontWeight.w700),
1005
+ ),
1006
+ const SizedBox(height: 4),
1007
+ Text(
1008
+ [
1009
+ session.locationSummary,
1010
+ 'Last seen ${session.lastSeenLabel}',
1011
+ ].join(' · '),
1012
+ style: TextStyle(color: _textSecondary),
1013
+ ),
1014
+ if (session.userAgent.isNotEmpty) ...<Widget>[
1015
+ const SizedBox(height: 4),
1016
+ Text(
1017
+ '${session.clientPlatformLabel} · ${session.clientBrowserLabel} · Created ${session.createdLabel}',
1018
+ maxLines: 2,
1019
+ overflow: TextOverflow.ellipsis,
1020
+ style: TextStyle(color: _textMuted, fontSize: 12),
1021
+ ),
1022
+ ],
1023
+ ],
1024
+ ),
1025
+ ),
1026
+ if (!session.current)
1027
+ TextButton(
1028
+ onPressed: busy ? null : onRevoke,
1029
+ child: Text('Revoke'),
1030
+ ),
1031
+ ],
1032
+ ),
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ class _QrLoginScannerDialog extends StatefulWidget {
1038
+ const _QrLoginScannerDialog();
1039
+
1040
+ @override
1041
+ State<_QrLoginScannerDialog> createState() => _QrLoginScannerDialogState();
1042
+ }
1043
+
1044
+ class _QrLoginScannerDialogState extends State<_QrLoginScannerDialog> {
1045
+ bool _handled = false;
1046
+
1047
+ @override
1048
+ Widget build(BuildContext context) {
1049
+ return Dialog.fullscreen(
1050
+ backgroundColor: Colors.black,
1051
+ child: Stack(
1052
+ fit: StackFit.expand,
1053
+ children: <Widget>[
1054
+ MobileScanner(
1055
+ fit: BoxFit.cover,
1056
+ onDetect: (capture) {
1057
+ if (_handled) return;
1058
+ final raw = capture.barcodes
1059
+ .map((barcode) => barcode.rawValue?.trim() ?? '')
1060
+ .firstWhere((value) => value.isNotEmpty, orElse: () => '');
1061
+ if (raw.isEmpty) return;
1062
+ _handled = true;
1063
+ Navigator.of(context).pop(raw);
1064
+ },
1065
+ ),
1066
+ DecoratedBox(
1067
+ decoration: BoxDecoration(
1068
+ gradient: LinearGradient(
1069
+ begin: Alignment.topCenter,
1070
+ end: Alignment.bottomCenter,
1071
+ colors: <Color>[
1072
+ Colors.black.withValues(alpha: 0.72),
1073
+ Colors.transparent,
1074
+ Colors.black.withValues(alpha: 0.78),
1075
+ ],
1076
+ stops: const <double>[0, 0.42, 1],
1077
+ ),
1078
+ ),
1079
+ ),
1080
+ SafeArea(
1081
+ child: Padding(
1082
+ padding: const EdgeInsets.all(20),
1083
+ child: Column(
1084
+ crossAxisAlignment: CrossAxisAlignment.start,
1085
+ children: <Widget>[
1086
+ Align(
1087
+ alignment: Alignment.topRight,
1088
+ child: IconButton(
1089
+ onPressed: () => Navigator.of(context).pop(),
1090
+ icon: const Icon(
1091
+ Icons.close_rounded,
1092
+ color: Colors.white,
1093
+ ),
1094
+ ),
1095
+ ),
1096
+ const Spacer(),
1097
+ Center(
1098
+ child: Container(
1099
+ width: 260,
1100
+ height: 260,
1101
+ decoration: BoxDecoration(
1102
+ borderRadius: BorderRadius.circular(28),
1103
+ border: Border.all(color: Colors.white, width: 2),
1104
+ ),
1105
+ ),
1106
+ ),
1107
+ const SizedBox(height: 28),
1108
+ Text(
1109
+ 'Scan a NeoAgent login QR',
1110
+ style: GoogleFonts.spaceGrotesk(
1111
+ fontSize: 28,
1112
+ fontWeight: FontWeight.w700,
1113
+ color: Colors.white,
1114
+ ),
1115
+ ),
1116
+ const SizedBox(height: 8),
1117
+ Text(
1118
+ 'Point the camera at the code shown on the signed-out device. Approval stays on this phone.',
1119
+ style: TextStyle(
1120
+ color: Colors.white.withValues(alpha: 0.82),
1121
+ height: 1.5,
1122
+ ),
1123
+ ),
1124
+ const SizedBox(height: 24),
1125
+ ],
1126
+ ),
1127
+ ),
1128
+ ),
1129
+ ],
1130
+ ),
1131
+ );
1132
+ }
1133
+ }
1134
+
1135
+ class _QrLoginApprovalDialog extends StatelessWidget {
1136
+ const _QrLoginApprovalDialog({required this.preview, required this.busy});
1137
+
1138
+ final QrLoginApprovalPreview preview;
1139
+ final bool busy;
1140
+
1141
+ IconData get _deviceIcon => switch (preview.requestedDevice.deviceClass) {
1142
+ 'mobile' => Icons.smartphone_rounded,
1143
+ 'tablet' => Icons.tablet_mac_rounded,
1144
+ 'desktop' => Icons.laptop_mac_rounded,
1145
+ 'server' => Icons.dns_outlined,
1146
+ _ => Icons.devices_other_outlined,
1147
+ };
1148
+
1149
+ @override
1150
+ Widget build(BuildContext context) {
1151
+ final canApprove =
1152
+ preview.canApprove && !preview.isExpired && !preview.isClaimed;
1153
+ return AlertDialog(
1154
+ backgroundColor: _bgCard,
1155
+ title: const Text('Approve QR login'),
1156
+ content: SizedBox(
1157
+ width: 460,
1158
+ child: Column(
1159
+ mainAxisSize: MainAxisSize.min,
1160
+ crossAxisAlignment: CrossAxisAlignment.start,
1161
+ children: <Widget>[
1162
+ Container(
1163
+ width: double.infinity,
1164
+ padding: const EdgeInsets.all(16),
1165
+ decoration: BoxDecoration(
1166
+ color: _bgSecondary,
1167
+ borderRadius: BorderRadius.circular(16),
1168
+ border: Border.all(color: _border),
1169
+ ),
1170
+ child: Row(
1171
+ crossAxisAlignment: CrossAxisAlignment.start,
1172
+ children: <Widget>[
1173
+ Icon(_deviceIcon, color: _accent),
1174
+ const SizedBox(width: 12),
1175
+ Expanded(
1176
+ child: Column(
1177
+ crossAxisAlignment: CrossAxisAlignment.start,
1178
+ children: <Widget>[
1179
+ Text(
1180
+ preview.requestedDevice.label,
1181
+ style: const TextStyle(fontWeight: FontWeight.w700),
1182
+ ),
1183
+ const SizedBox(height: 6),
1184
+ Text(
1185
+ [
1186
+ preview.requestLocation.label,
1187
+ if (preview.requestedAt != null)
1188
+ 'Requested ${_formatTimestamp(preview.requestedAt!)}',
1189
+ ].join(' · '),
1190
+ style: TextStyle(color: _textSecondary, height: 1.4),
1191
+ ),
1192
+ ],
1193
+ ),
1194
+ ),
1195
+ ],
1196
+ ),
1197
+ ),
1198
+ const SizedBox(height: 14),
1199
+ Wrap(
1200
+ spacing: 8,
1201
+ runSpacing: 8,
1202
+ children: <Widget>[
1203
+ _MetaPill(
1204
+ label: preview.requestedDevice.platformLabel,
1205
+ icon: Icons.devices_outlined,
1206
+ ),
1207
+ _MetaPill(
1208
+ label: preview.requestedDevice.browserLabel,
1209
+ icon: Icons.language_outlined,
1210
+ ),
1211
+ if (preview.expiresAt != null)
1212
+ _MetaPill(
1213
+ label: 'Expires ${_formatTimestamp(preview.expiresAt!)}',
1214
+ icon: Icons.timer_outlined,
1215
+ ),
1216
+ ],
1217
+ ),
1218
+ const SizedBox(height: 14),
1219
+ Text(
1220
+ preview.isClaimed
1221
+ ? 'This request has already been used.'
1222
+ : preview.isExpired
1223
+ ? 'This request has expired. Ask the other device to generate a new code.'
1224
+ : 'Approve this only if you started the login on that device just now.',
1225
+ style: TextStyle(color: _textSecondary, height: 1.45),
1226
+ ),
1227
+ ],
1228
+ ),
1229
+ ),
1230
+ actions: <Widget>[
1231
+ TextButton(
1232
+ onPressed: busy ? null : () => Navigator.of(context).pop(false),
1233
+ child: const Text('Cancel'),
1234
+ ),
1235
+ FilledButton.icon(
1236
+ onPressed: !canApprove || busy
1237
+ ? null
1238
+ : () => Navigator.of(context).pop(true),
1239
+ icon: busy
1240
+ ? const SizedBox.square(
1241
+ dimension: 16,
1242
+ child: CircularProgressIndicator(strokeWidth: 2),
1243
+ )
1244
+ : const Icon(Icons.verified_user_outlined),
1245
+ label: const Text('Approve login'),
1246
+ ),
1247
+ ],
1248
+ );
1249
+ }
1250
+ }