neoagent 3.0.1-beta.9 → 3.1.0

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 (97) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.js +436 -10
  42. package/server/admin/billing.js +158 -35
  43. package/server/admin/index.html +72 -2
  44. package/server/admin/users.js +15 -15
  45. package/server/db/database.js +125 -20
  46. package/server/http/routes.js +1 -0
  47. package/server/http/static.js +4 -2
  48. package/server/public/.last_build_id +1 -1
  49. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  50. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  51. package/server/public/canvaskit/wimp.wasm +0 -0
  52. package/server/public/flutter_bootstrap.js +2 -2
  53. package/server/public/main.dart.js +91077 -87037
  54. package/server/routes/account.js +53 -0
  55. package/server/routes/admin.js +345 -64
  56. package/server/routes/agents.js +203 -21
  57. package/server/routes/billing.js +5 -0
  58. package/server/routes/browser.js +8 -1
  59. package/server/routes/recordings.js +96 -6
  60. package/server/routes/screenHistory.js +140 -2
  61. package/server/routes/timeline.js +43 -0
  62. package/server/services/account/erasure.js +263 -0
  63. package/server/services/ai/hooks.js +4 -1
  64. package/server/services/ai/loop/agent_engine_core.js +8 -1
  65. package/server/services/ai/loop/blank_recovery.js +36 -0
  66. package/server/services/ai/loop/conversation_loop.js +166 -33
  67. package/server/services/ai/messagingFallback.js +22 -0
  68. package/server/services/ai/rate_limits.js +28 -5
  69. package/server/services/ai/systemPrompt.js +6 -5
  70. package/server/services/ai/taskAnalysis.js +2 -0
  71. package/server/services/ai/toolEvidence.js +15 -0
  72. package/server/services/ai/toolResult.js +40 -0
  73. package/server/services/ai/tools.js +163 -4
  74. package/server/services/android/controller.js +6 -2
  75. package/server/services/billing/plans.js +2 -1
  76. package/server/services/browser/anti_detection.js +192 -0
  77. package/server/services/browser/controller.js +180 -54
  78. package/server/services/desktop/auth.js +3 -0
  79. package/server/services/desktop/registry.js +50 -2
  80. package/server/services/integrations/google/calendar.js +22 -14
  81. package/server/services/manager.js +12 -42
  82. package/server/services/memory/ingestion_chunking.js +268 -0
  83. package/server/services/messaging/telnyx.js +9 -8
  84. package/server/services/recordings/manager.js +60 -27
  85. package/server/services/runtime/docker-vm-manager.js +157 -266
  86. package/server/services/runtime/guest_bootstrap.js +17 -5
  87. package/server/services/runtime/guest_image.js +188 -0
  88. package/server/services/runtime/manager.js +0 -1
  89. package/server/services/runtime/validation.js +3 -8
  90. package/server/services/social_video/service.js +60 -10
  91. package/server/services/tasks/runtime.js +234 -9
  92. package/server/services/tasks/task_repository.js +13 -0
  93. package/server/services/timeline/service.js +558 -0
  94. package/server/services/wearable/gateway.js +1 -1
  95. package/server/services/websocket.js +21 -3
  96. package/server/services/desktop/screenRecorder.js +0 -292
  97. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -1,5 +1,7 @@
1
1
  part of 'main.dart';
2
2
 
3
+ enum _LeaveAction { save, discard, cancel }
4
+
3
5
  class SettingsPanel extends StatefulWidget {
4
6
  const SettingsPanel({
5
7
  super.key,
@@ -190,6 +192,8 @@ class _SettingsPanelState extends State<SettingsPanel> {
190
192
  final Map<String, TextEditingController> _providerBaseUrlControllers =
191
193
  <String, TextEditingController>{};
192
194
 
195
+ bool _hasUnsavedChanges = false;
196
+
193
197
  // Inline runtime test state — ephemeral, not stored in controller.
194
198
  bool _cliTestRunning = false;
195
199
  Map<String, dynamic>? _cliTestResult;
@@ -314,8 +318,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
314
318
  final routingModels = availableModels.isEmpty
315
319
  ? controller.supportedModels
316
320
  : availableModels;
317
- final List<_ModelPickerOption> modelChoices =
318
- _modelPickerOptions(routingModels, allowAuto: true);
321
+ final List<_ModelPickerOption> modelChoices = _modelPickerOptions(
322
+ routingModels,
323
+ allowAuto: true,
324
+ );
319
325
  final enabledSmartModels = _enabledModels
320
326
  .where((id) => routingModels.any((model) => model.id == id))
321
327
  .length;
@@ -323,113 +329,131 @@ class _SettingsPanelState extends State<SettingsPanel> {
323
329
  .where((section) => !section.requiresDesktop || _supportsDesktopShell)
324
330
  .toSet();
325
331
 
326
- return ListView(
327
- padding: widget.embedded ? EdgeInsets.zero : _pagePadding(context),
328
- children: <Widget>[
329
- if (!widget.embedded)
330
- _PageTitle(
331
- title: 'Settings',
332
- subtitle:
333
- 'Workspace, models, recording, and diagnostics controls.',
334
- trailing: _settingsSaveButton(controller),
335
- )
336
- else
337
- Align(
338
- alignment: Alignment.centerRight,
339
- child: Padding(
340
- padding: const EdgeInsets.only(bottom: 12),
341
- child: _settingsSaveButton(controller),
332
+ return PopScope(
333
+ canPop: !_hasUnsavedChanges,
334
+ onPopInvokedWithResult: (didPop, result) async {
335
+ if (didPop) return;
336
+ final action = await _showLeaveDialog(context);
337
+ if (action == _LeaveAction.save && mounted) {
338
+ await _doSave();
339
+ if (mounted) Navigator.of(context).pop();
340
+ } else if (action == _LeaveAction.discard && mounted) {
341
+ _hydrate();
342
+ setState(() => _hasUnsavedChanges = false);
343
+ Navigator.of(context).pop();
344
+ }
345
+ // cancel: do nothing
346
+ },
347
+ child: ListView(
348
+ padding: widget.embedded ? EdgeInsets.zero : _pagePadding(context),
349
+ children: <Widget>[
350
+ if (!widget.embedded)
351
+ _PageTitle(
352
+ title: 'Settings',
353
+ subtitle:
354
+ 'Workspace, models, recording, and diagnostics controls.',
355
+ trailing: _settingsSaveButton(controller),
356
+ )
357
+ else
358
+ Align(
359
+ alignment: Alignment.centerRight,
360
+ child: Padding(
361
+ padding: const EdgeInsets.only(bottom: 12),
362
+ child: _settingsSaveButton(controller),
363
+ ),
364
+ ),
365
+ if (controller.errorMessage != null) ...<Widget>[
366
+ _InlineError(message: controller.errorMessage!),
367
+ const SizedBox(height: 16),
368
+ ],
369
+ TextField(
370
+ controller: _searchController,
371
+ onChanged: (_) => setState(() {}),
372
+ decoration: InputDecoration(
373
+ labelText: 'Search settings',
374
+ hintText: 'Models, browser, voice, diagnostics...',
375
+ prefixIcon: const Icon(Icons.search),
376
+ suffixIcon: searchQuery.isEmpty
377
+ ? null
378
+ : IconButton(
379
+ onPressed: () {
380
+ _searchController.clear();
381
+ setState(() {});
382
+ },
383
+ icon: const Icon(Icons.close),
384
+ ),
342
385
  ),
343
386
  ),
344
- if (controller.errorMessage != null) ...<Widget>[
345
- _InlineError(message: controller.errorMessage!),
346
- const SizedBox(height: 16),
347
- ],
348
- TextField(
349
- controller: _searchController,
350
- onChanged: (_) => setState(() {}),
351
- decoration: InputDecoration(
352
- labelText: 'Search settings',
353
- hintText: 'Models, browser, voice, diagnostics...',
354
- prefixIcon: const Icon(Icons.search),
355
- suffixIcon: searchQuery.isEmpty
356
- ? null
357
- : IconButton(
358
- onPressed: () {
359
- _searchController.clear();
360
- setState(() {});
361
- },
362
- icon: const Icon(Icons.close),
363
- ),
364
- ),
365
- ),
366
- const SizedBox(height: 16),
367
- if (_matchesSettingsSection(
368
- searchQuery,
369
- _overviewSettingsSection,
370
- )) ...<Widget>[
371
- _buildSettingsOverview(controller, availableModels.length),
372
- const SizedBox(height: 16),
373
- ],
374
- if (_matchesSettingsSection(
375
- searchQuery,
376
- _workspaceSettingsSection,
377
- )) ...<Widget>[
378
- _buildWorkspaceSection(controller),
379
- const SizedBox(height: 16),
380
- ],
381
- if (_matchesSettingsSection(
382
- searchQuery,
383
- _modelsSettingsSection,
384
- )) ...<Widget>[
385
- _buildModelsSection(
386
- context: context,
387
- controller: controller,
388
- modelChoices: modelChoices,
389
- routingModels: routingModels,
390
- availableModels: availableModels,
391
- enabledSmartModels: enabledSmartModels,
392
- ),
393
- const SizedBox(height: 16),
394
- ],
395
- if (_matchesSettingsSection(
396
- searchQuery,
397
- _voiceRecordingSettingsSection,
398
- )) ...<Widget>[
399
- _buildVoiceAndRecordingSection(
400
- controller: controller,
401
- modelChoices: modelChoices,
402
- routingModels: routingModels,
403
- ),
404
- const SizedBox(height: 16),
405
- ],
406
- if (visibleSearchSections.contains(_desktopSettingsSection) &&
407
- _matchesSettingsSection(
408
- searchQuery,
409
- _desktopSettingsSection,
410
- )) ...<Widget>[
411
- _buildDesktopSection(controller),
412
- const SizedBox(height: 16),
413
- ],
414
- if (_matchesSettingsSection(
415
- searchQuery,
416
- _securitySettingsSection,
417
- )) ...<Widget>[
418
- _buildSecuritySection(context, controller),
419
387
  const SizedBox(height: 16),
388
+ if (_matchesSettingsSection(
389
+ searchQuery,
390
+ _overviewSettingsSection,
391
+ )) ...<Widget>[
392
+ _buildSettingsOverview(controller, availableModels.length),
393
+ const SizedBox(height: 16),
394
+ ],
395
+ if (_matchesSettingsSection(
396
+ searchQuery,
397
+ _workspaceSettingsSection,
398
+ )) ...<Widget>[
399
+ _buildWorkspaceSection(controller),
400
+ const SizedBox(height: 16),
401
+ ],
402
+ if (_matchesSettingsSection(
403
+ searchQuery,
404
+ _modelsSettingsSection,
405
+ )) ...<Widget>[
406
+ _buildModelsSection(
407
+ context: context,
408
+ controller: controller,
409
+ modelChoices: modelChoices,
410
+ routingModels: routingModels,
411
+ availableModels: availableModels,
412
+ enabledSmartModels: enabledSmartModels,
413
+ ),
414
+ const SizedBox(height: 16),
415
+ ],
416
+ if (_matchesSettingsSection(
417
+ searchQuery,
418
+ _voiceRecordingSettingsSection,
419
+ )) ...<Widget>[
420
+ _buildVoiceAndRecordingSection(
421
+ controller: controller,
422
+ modelChoices: modelChoices,
423
+ routingModels: routingModels,
424
+ ),
425
+ const SizedBox(height: 16),
426
+ ],
427
+ if (visibleSearchSections.contains(_desktopSettingsSection) &&
428
+ _matchesSettingsSection(
429
+ searchQuery,
430
+ _desktopSettingsSection,
431
+ )) ...<Widget>[
432
+ _buildDesktopSection(controller),
433
+ const SizedBox(height: 16),
434
+ ],
435
+ if (_matchesSettingsSection(
436
+ searchQuery,
437
+ _securitySettingsSection,
438
+ )) ...<Widget>[
439
+ _buildSecuritySection(context, controller),
440
+ const SizedBox(height: 16),
441
+ ],
442
+ if (_matchesSettingsSection(
443
+ searchQuery,
444
+ _diagnosticsSettingsSection,
445
+ )) ...<Widget>[_buildDiagnosticsSection(controller)],
446
+ if (_noSettingsMatches(
447
+ searchQuery,
448
+ visibleSearchSections,
449
+ )) ...<Widget>[
450
+ const _EmptyCard(
451
+ title: 'No matching settings',
452
+ subtitle: 'Try a broader search like models, browser, or voice.',
453
+ ),
454
+ ],
420
455
  ],
421
- if (_matchesSettingsSection(
422
- searchQuery,
423
- _diagnosticsSettingsSection,
424
- )) ...<Widget>[_buildDiagnosticsSection(controller)],
425
- if (_noSettingsMatches(searchQuery, visibleSearchSections)) ...<Widget>[
426
- const _EmptyCard(
427
- title: 'No matching settings',
428
- subtitle:
429
- 'Try a broader search like models, browser, or voice.',
430
- ),
431
- ],
432
- ],
456
+ ),
433
457
  );
434
458
  }
435
459
 
@@ -456,44 +480,45 @@ class _SettingsPanelState extends State<SettingsPanel> {
456
480
  );
457
481
  }
458
482
 
483
+ Future<void> _doSave() async {
484
+ final controller = widget.controller;
485
+ await controller.saveSettings(
486
+ browserBackend: _browserBackend == 'extension' ? 'extension' : 'vm',
487
+ browserExtensionTokenId: _browserBackend == 'extension'
488
+ ? _browserExtensionTokenId
489
+ : null,
490
+ cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
491
+ cliDesktopDeviceId: _cliDesktopDeviceId,
492
+ smarterSelector: _smarterSelector,
493
+ enabledModels: _enabledModels.toList(),
494
+ defaultChatModel: _defaultChatModel,
495
+ defaultSubagentModel: _defaultSubagentModel,
496
+ defaultRecordingTranscriptionProvider: 'deepgram',
497
+ defaultRecordingTranscriptionModel: _defaultRecordingTranscriptionModel,
498
+ defaultRecordingSummaryProvider: _providerForSelectedModel(
499
+ _defaultRecordingSummaryModel,
500
+ controller.supportedModels,
501
+ ),
502
+ defaultRecordingSummaryModel: _defaultRecordingSummaryModel,
503
+ fallbackModel: _fallbackModel,
504
+ defaultSpeechModel: _defaultSpeechModel,
505
+ voiceSttProvider: controller.voiceSttProvider,
506
+ voiceSttModel: controller.voiceSttModel,
507
+ voiceTtsProvider: controller.voiceTtsProvider,
508
+ voiceTtsModel: controller.voiceTtsModel,
509
+ voiceTtsVoice: controller.voiceTtsVoice,
510
+ voiceRuntimeMode: 'live',
511
+ voiceLiveProvider: _voiceLiveProvider,
512
+ voiceLiveModel: _voiceLiveModel,
513
+ voiceLiveVoice: _voiceLiveVoice,
514
+ aiProviderConfigs: _buildProviderPayload(),
515
+ );
516
+ if (mounted) setState(() => _hasUnsavedChanges = false);
517
+ }
518
+
459
519
  Widget _settingsSaveButton(NeoAgentController controller) {
460
- return FilledButton.icon(
461
- onPressed: controller.isSavingSettings
462
- ? null
463
- : () => controller.saveSettings(
464
- browserBackend: _browserBackend == 'extension'
465
- ? 'extension'
466
- : 'vm',
467
- browserExtensionTokenId: _browserBackend == 'extension'
468
- ? _browserExtensionTokenId
469
- : null,
470
- cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
471
- cliDesktopDeviceId: _cliDesktopDeviceId,
472
- smarterSelector: _smarterSelector,
473
- enabledModels: _enabledModels.toList(),
474
- defaultChatModel: _defaultChatModel,
475
- defaultSubagentModel: _defaultSubagentModel,
476
- defaultRecordingTranscriptionProvider: 'deepgram',
477
- defaultRecordingTranscriptionModel:
478
- _defaultRecordingTranscriptionModel,
479
- defaultRecordingSummaryProvider: _providerForSelectedModel(
480
- _defaultRecordingSummaryModel,
481
- controller.supportedModels,
482
- ),
483
- defaultRecordingSummaryModel: _defaultRecordingSummaryModel,
484
- fallbackModel: _fallbackModel,
485
- defaultSpeechModel: _defaultSpeechModel,
486
- voiceSttProvider: controller.voiceSttProvider,
487
- voiceSttModel: controller.voiceSttModel,
488
- voiceTtsProvider: controller.voiceTtsProvider,
489
- voiceTtsModel: controller.voiceTtsModel,
490
- voiceTtsVoice: controller.voiceTtsVoice,
491
- voiceRuntimeMode: 'live',
492
- voiceLiveProvider: _voiceLiveProvider,
493
- voiceLiveModel: _voiceLiveModel,
494
- voiceLiveVoice: _voiceLiveVoice,
495
- aiProviderConfigs: _buildProviderPayload(),
496
- ),
520
+ final button = FilledButton.icon(
521
+ onPressed: controller.isSavingSettings ? null : _doSave,
497
522
  style: FilledButton.styleFrom(backgroundColor: _accent),
498
523
  icon: controller.isSavingSettings
499
524
  ? const SizedBox.square(
@@ -506,6 +531,19 @@ class _SettingsPanelState extends State<SettingsPanel> {
506
531
  : Icon(Icons.save_outlined),
507
532
  label: Text('Save'),
508
533
  );
534
+ if (!_hasUnsavedChanges) return button;
535
+ return Column(
536
+ crossAxisAlignment: CrossAxisAlignment.end,
537
+ mainAxisSize: MainAxisSize.min,
538
+ children: <Widget>[
539
+ Text(
540
+ 'Unsaved changes',
541
+ style: TextStyle(color: Colors.orange, fontSize: 12),
542
+ ),
543
+ const SizedBox(height: 4),
544
+ button,
545
+ ],
546
+ );
509
547
  }
510
548
 
511
549
  Widget _buildSettingsOverview(
@@ -623,6 +661,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
623
661
  _browserBackend = value;
624
662
  _browserExtensionTokenId ??=
625
663
  controller.selectedBrowserExtensionTokenId;
664
+ _hasUnsavedChanges = true;
626
665
  });
627
666
  }
628
667
  },
@@ -631,21 +670,27 @@ class _SettingsPanelState extends State<SettingsPanel> {
631
670
  if (_browserBackend == 'extension') ...<Widget>[
632
671
  if (controller.browserExtensionTokens.isNotEmpty) ...<Widget>[
633
672
  DropdownButtonFormField<String>(
634
- initialValue: controller.browserExtensionTokens.any(
635
- (token) => token['tokenId']?.toString() == _browserExtensionTokenId,
636
- )
673
+ initialValue:
674
+ controller.browserExtensionTokens.any(
675
+ (token) =>
676
+ token['tokenId']?.toString() ==
677
+ _browserExtensionTokenId,
678
+ )
637
679
  ? _browserExtensionTokenId
638
680
  : null,
639
681
  decoration: const InputDecoration(
640
682
  labelText: 'Default extension',
641
- helperText: 'Choose which paired Chrome extension controls browser actions.',
683
+ helperText:
684
+ 'Choose which paired Chrome extension controls browser actions.',
642
685
  ),
643
686
  items: controller.browserExtensionTokens.map((token) {
644
687
  final tokenId = token['tokenId']?.toString() ?? '';
645
- final label = token['name']?.toString().trim().isNotEmpty == true
688
+ final label =
689
+ token['name']?.toString().trim().isNotEmpty == true
646
690
  ? token['name'].toString()
647
691
  : tokenId;
648
- final online = token['online'] == true || token['connected'] == true;
692
+ final online =
693
+ token['online'] == true || token['connected'] == true;
649
694
  return DropdownMenuItem<String>(
650
695
  value: tokenId,
651
696
  child: Row(
@@ -669,7 +714,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
669
714
  }).toList(),
670
715
  onChanged: (value) {
671
716
  if (value != null) {
672
- setState(() => _browserExtensionTokenId = value);
717
+ setState(() {
718
+ _browserExtensionTokenId = value;
719
+ _hasUnsavedChanges = true;
720
+ });
673
721
  }
674
722
  },
675
723
  ),
@@ -683,7 +731,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
683
731
  ? 'Connected — tap Test to verify the live link.'
684
732
  : 'Not connected — download the extension, load it in Chrome, then pair after login.',
685
733
  onTest: () async {
686
- setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
734
+ setState(() {
735
+ _extensionTestRunning = true;
736
+ _extensionTestResult = null;
737
+ });
687
738
  try {
688
739
  final r = await controller.testBrowserExtension();
689
740
  if (mounted) {
@@ -691,7 +742,12 @@ class _SettingsPanelState extends State<SettingsPanel> {
691
742
  }
692
743
  } catch (e) {
693
744
  if (mounted) {
694
- setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
745
+ setState(
746
+ () => _extensionTestResult = <String, dynamic>{
747
+ 'passed': false,
748
+ 'detail': e.toString(),
749
+ },
750
+ );
695
751
  }
696
752
  } finally {
697
753
  if (mounted) {
@@ -718,7 +774,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
718
774
  ],
719
775
  ),
720
776
  ] else ...<Widget>[
721
- Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
777
+ Text(
778
+ 'Cloud browser runtime is active.',
779
+ style: TextStyle(color: _textSecondary, height: 1.4),
780
+ ),
722
781
  ],
723
782
  const Divider(height: 32),
724
783
  Text(
@@ -746,25 +805,32 @@ class _SettingsPanelState extends State<SettingsPanel> {
746
805
  ],
747
806
  onChanged: (value) {
748
807
  if (value != null) {
749
- setState(() => _cliBackend = value);
808
+ setState(() {
809
+ _cliBackend = value;
810
+ _hasUnsavedChanges = true;
811
+ });
750
812
  }
751
813
  },
752
814
  ),
753
- if (_cliBackend == 'desktop' && controller.desktopDevices.length > 1) ...<Widget>[
815
+ if (_cliBackend == 'desktop' &&
816
+ controller.desktopDevices.length > 1) ...<Widget>[
754
817
  const SizedBox(height: 12),
755
818
  DropdownButtonFormField<String>(
756
- initialValue: controller.desktopDevices.any(
757
- (d) => d['deviceId']?.toString() == _cliDesktopDeviceId,
758
- )
819
+ initialValue:
820
+ controller.desktopDevices.any(
821
+ (d) => d['deviceId']?.toString() == _cliDesktopDeviceId,
822
+ )
759
823
  ? _cliDesktopDeviceId
760
824
  : null,
761
825
  decoration: const InputDecoration(
762
826
  labelText: 'Desktop device',
763
- helperText: 'Choose which desktop companion runs CLI commands.',
827
+ helperText:
828
+ 'Choose which desktop companion runs CLI commands.',
764
829
  ),
765
830
  items: controller.desktopDevices.map((device) {
766
831
  final deviceId = device['deviceId']?.toString() ?? '';
767
- final label = device['hostname']?.toString().isNotEmpty == true
832
+ final label =
833
+ device['hostname']?.toString().isNotEmpty == true
768
834
  ? device['hostname']!.toString()
769
835
  : deviceId;
770
836
  final online = device['online'] == true;
@@ -785,7 +851,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
785
851
  }).toList(),
786
852
  onChanged: (value) {
787
853
  if (value != null) {
788
- setState(() => _cliDesktopDeviceId = value);
854
+ setState(() {
855
+ _cliDesktopDeviceId = value;
856
+ _hasUnsavedChanges = true;
857
+ });
789
858
  }
790
859
  },
791
860
  ),
@@ -797,11 +866,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
797
866
  result: _cliTestResult,
798
867
  note: _cliBackend == 'desktop'
799
868
  ? (controller.desktopCompanionConnected
800
- ? 'Desktop app connected — commands route locally through the companion.'
801
- : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
869
+ ? 'Desktop app connected — commands route locally through the companion.'
870
+ : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
802
871
  : 'Cloud VM — commands run in an isolated container.',
803
872
  onTest: () async {
804
- setState(() { _cliTestRunning = true; _cliTestResult = null; });
873
+ setState(() {
874
+ _cliTestRunning = true;
875
+ _cliTestResult = null;
876
+ });
805
877
  try {
806
878
  final r = await controller.testCliRuntime();
807
879
  if (mounted) {
@@ -809,7 +881,12 @@ class _SettingsPanelState extends State<SettingsPanel> {
809
881
  }
810
882
  } catch (e) {
811
883
  if (mounted) {
812
- setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
884
+ setState(
885
+ () => _cliTestResult = <String, dynamic>{
886
+ 'passed': false,
887
+ 'detail': e.toString(),
888
+ },
889
+ );
813
890
  }
814
891
  } finally {
815
892
  if (mounted) {
@@ -832,7 +909,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
832
909
  subtitle:
833
910
  'Automatically choose the best enabled model for each task type.',
834
911
  value: _smarterSelector,
835
- onChanged: (value) => setState(() => _smarterSelector = value),
912
+ onChanged: (value) => setState(() {
913
+ _smarterSelector = value;
914
+ _hasUnsavedChanges = true;
915
+ }),
836
916
  ),
837
917
  ],
838
918
  ),
@@ -893,7 +973,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
893
973
  options: modelChoices,
894
974
  onChanged: (value) {
895
975
  if (value != null) {
896
- setState(() => _defaultChatModel = value);
976
+ setState(() {
977
+ _defaultChatModel = value;
978
+ _hasUnsavedChanges = true;
979
+ });
897
980
  }
898
981
  },
899
982
  ),
@@ -911,7 +994,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
911
994
  options: modelChoices,
912
995
  onChanged: (value) {
913
996
  if (value != null) {
914
- setState(() => _defaultSubagentModel = value);
997
+ setState(() {
998
+ _defaultSubagentModel = value;
999
+ _hasUnsavedChanges = true;
1000
+ });
915
1001
  }
916
1002
  },
917
1003
  ),
@@ -929,7 +1015,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
929
1015
  options: _modelPickerOptions(routingModels),
930
1016
  onChanged: (value) {
931
1017
  if (value != null) {
932
- setState(() => _fallbackModel = value);
1018
+ setState(() {
1019
+ _fallbackModel = value;
1020
+ _hasUnsavedChanges = true;
1021
+ });
933
1022
  }
934
1023
  },
935
1024
  ),
@@ -963,12 +1052,21 @@ class _SettingsPanelState extends State<SettingsPanel> {
963
1052
  barrierColor: Colors.black.withValues(alpha: 0.55),
964
1053
  transitionDuration: const Duration(milliseconds: 220),
965
1054
  transitionBuilder: (ctx, anim, _, child) => FadeTransition(
966
- opacity: CurvedAnimation(parent: anim, curve: Curves.easeOut),
1055
+ opacity: CurvedAnimation(
1056
+ parent: anim,
1057
+ curve: Curves.easeOut,
1058
+ ),
967
1059
  child: SlideTransition(
968
- position: Tween<Offset>(
969
- begin: const Offset(0, 0.04),
970
- end: Offset.zero,
971
- ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
1060
+ position:
1061
+ Tween<Offset>(
1062
+ begin: const Offset(0, 0.04),
1063
+ end: Offset.zero,
1064
+ ).animate(
1065
+ CurvedAnimation(
1066
+ parent: anim,
1067
+ curve: Curves.easeOutCubic,
1068
+ ),
1069
+ ),
972
1070
  child: child,
973
1071
  ),
974
1072
  ),
@@ -977,7 +1075,11 @@ class _SettingsPanelState extends State<SettingsPanel> {
977
1075
  selectedIds: _enabledModels,
978
1076
  ),
979
1077
  );
980
- if (result != null) setState(() => _enabledModels = result);
1078
+ if (result != null)
1079
+ setState(() {
1080
+ _enabledModels = result;
1081
+ _hasUnsavedChanges = true;
1082
+ });
981
1083
  },
982
1084
  ),
983
1085
  ],
@@ -1037,9 +1139,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1037
1139
  options: modelChoices,
1038
1140
  onChanged: (value) {
1039
1141
  if (value != null) {
1040
- setState(
1041
- () => _defaultRecordingSummaryModel = value,
1042
- );
1142
+ setState(() {
1143
+ _defaultRecordingSummaryModel = value;
1144
+ _hasUnsavedChanges = true;
1145
+ });
1043
1146
  }
1044
1147
  },
1045
1148
  ),
@@ -1057,6 +1160,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1057
1160
  if (value != null) {
1058
1161
  setState(() {
1059
1162
  _defaultRecordingTranscriptionModel = value;
1163
+ _hasUnsavedChanges = true;
1060
1164
  });
1061
1165
  }
1062
1166
  },
@@ -1098,7 +1202,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1098
1202
  options: modelChoices,
1099
1203
  onChanged: (value) {
1100
1204
  if (value != null) {
1101
- setState(() => _defaultSpeechModel = value);
1205
+ setState(() {
1206
+ _defaultSpeechModel = value;
1207
+ _hasUnsavedChanges = true;
1208
+ });
1102
1209
  }
1103
1210
  },
1104
1211
  ),
@@ -1137,9 +1244,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1137
1244
  label: 'Live Provider',
1138
1245
  icon: Icons.call_outlined,
1139
1246
  value: _voiceLiveProvider,
1140
- options: _simplePickerOptions(
1141
- const <String>['openai', 'gemini'],
1142
- ),
1247
+ options: _simplePickerOptions(const <String>[
1248
+ 'openai',
1249
+ 'gemini',
1250
+ ]),
1143
1251
  onChanged: (value) {
1144
1252
  if (value == null) return;
1145
1253
  setState(() {
@@ -1158,6 +1266,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1158
1266
  !voiceOptions.contains(_voiceLiveVoice)) {
1159
1267
  _voiceLiveVoice = voiceOptions.first;
1160
1268
  }
1269
+ _hasUnsavedChanges = true;
1161
1270
  });
1162
1271
  },
1163
1272
  ),
@@ -1174,7 +1283,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1174
1283
  ),
1175
1284
  onChanged: (value) {
1176
1285
  if (value != null) {
1177
- setState(() => _voiceLiveModel = value);
1286
+ setState(() {
1287
+ _voiceLiveModel = value;
1288
+ _hasUnsavedChanges = true;
1289
+ });
1178
1290
  }
1179
1291
  },
1180
1292
  ),
@@ -1189,7 +1301,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1189
1301
  options: _simplePickerOptions(liveVoiceOptions),
1190
1302
  onChanged: (value) {
1191
1303
  if (value != null) {
1192
- setState(() => _voiceLiveVoice = value);
1304
+ setState(() {
1305
+ _voiceLiveVoice = value;
1306
+ _hasUnsavedChanges = true;
1307
+ });
1193
1308
  }
1194
1309
  },
1195
1310
  ),
@@ -1307,6 +1422,18 @@ class _SettingsPanelState extends State<SettingsPanel> {
1307
1422
  ? controller.setDesktopCompanionPaused
1308
1423
  : null,
1309
1424
  ),
1425
+ SwitchListTile.adaptive(
1426
+ value: controller.desktopPassiveHistoryEnabled,
1427
+ contentPadding: EdgeInsets.zero,
1428
+ title: Text('Passive screen history on this computer'),
1429
+ subtitle: Text(
1430
+ 'Capture OCR text plus app/window context locally for the user-wide Timeline. Screenshots stay transient and are not uploaded.',
1431
+ style: TextStyle(color: _textSecondary),
1432
+ ),
1433
+ onChanged: controller.desktopCompanionEnabled
1434
+ ? controller.setDesktopPassiveHistoryEnabled
1435
+ : null,
1436
+ ),
1310
1437
  const SizedBox(height: 12),
1311
1438
  TextFormField(
1312
1439
  initialValue: controller.desktopCompanionLabel,
@@ -1341,8 +1468,49 @@ class _SettingsPanelState extends State<SettingsPanel> {
1341
1468
  ? _warning
1342
1469
  : _success,
1343
1470
  ),
1471
+ _DotStatus(
1472
+ label: controller.desktopPassiveHistoryEnabled
1473
+ ? 'History enabled'
1474
+ : 'History disabled',
1475
+ color: controller.desktopPassiveHistoryEnabled
1476
+ ? _accent
1477
+ : _textMuted,
1478
+ ),
1344
1479
  ],
1345
1480
  ),
1481
+ if (controller.desktopPassiveHistoryLastUploadedAt != null ||
1482
+ controller.desktopPassiveHistoryLastError != null) ...<Widget>[
1483
+ const SizedBox(height: 12),
1484
+ Container(
1485
+ width: double.infinity,
1486
+ padding: const EdgeInsets.all(12),
1487
+ decoration: BoxDecoration(
1488
+ color: _bgSecondary.withValues(alpha: 0.66),
1489
+ borderRadius: BorderRadius.circular(16),
1490
+ border: Border.all(color: _borderLight),
1491
+ ),
1492
+ child: Column(
1493
+ crossAxisAlignment: CrossAxisAlignment.start,
1494
+ children: <Widget>[
1495
+ if (controller.desktopPassiveHistoryLastUploadedAt
1496
+ case final uploadedAt?)
1497
+ Text(
1498
+ 'Last upload: ${_formatTimestamp(DateTime.parse(uploadedAt))}',
1499
+ style: TextStyle(color: _textSecondary),
1500
+ ),
1501
+ if (controller.desktopPassiveHistoryLastError
1502
+ case final passiveError?)
1503
+ Padding(
1504
+ padding: const EdgeInsets.only(top: 6),
1505
+ child: Text(
1506
+ 'Last error: $passiveError',
1507
+ style: TextStyle(color: _danger),
1508
+ ),
1509
+ ),
1510
+ ],
1511
+ ),
1512
+ ),
1513
+ ],
1346
1514
  if (controller.desktopCompanionErrorMessage
1347
1515
  case final message?) ...<Widget>[
1348
1516
  const SizedBox(height: 12),
@@ -1362,7 +1530,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1362
1530
  ? 'Connected — tap Test to fetch live device status from the server.'
1363
1531
  : 'Not connected. Make sure the desktop app is running on the target machine.',
1364
1532
  onTest: () async {
1365
- setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
1533
+ setState(() {
1534
+ _desktopTestRunning = true;
1535
+ _desktopTestResult = null;
1536
+ });
1366
1537
  try {
1367
1538
  final r = await controller.testDesktopCompanion();
1368
1539
  final active = r['activeDevice'];
@@ -1373,20 +1544,29 @@ class _SettingsPanelState extends State<SettingsPanel> {
1373
1544
  final plat = active['platform']?.toString() ?? '';
1374
1545
  final sc = active['permissions']?['screenCapture'] == true;
1375
1546
  final ic = active['permissions']?['inputControl'] == true;
1376
- detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
1547
+ detail =
1548
+ '$label${plat.isNotEmpty ? " ($plat)" : ""}'
1377
1549
  ' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
1378
1550
  } else if (multi) {
1379
- detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
1551
+ detail =
1552
+ '${r['onlineCount']} devices online — select one in Desktop › Devices';
1380
1553
  }
1381
1554
  if (mounted) {
1382
- setState(() => _desktopTestResult = <String, dynamic>{
1383
- ...r,
1384
- 'detail': detail,
1385
- });
1555
+ setState(
1556
+ () => _desktopTestResult = <String, dynamic>{
1557
+ ...r,
1558
+ 'detail': detail,
1559
+ },
1560
+ );
1386
1561
  }
1387
1562
  } catch (e) {
1388
1563
  if (mounted) {
1389
- setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1564
+ setState(
1565
+ () => _desktopTestResult = <String, dynamic>{
1566
+ 'passed': false,
1567
+ 'detail': e.toString(),
1568
+ },
1569
+ );
1390
1570
  }
1391
1571
  } finally {
1392
1572
  if (mounted) {
@@ -1511,7 +1691,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1511
1691
  );
1512
1692
  }
1513
1693
 
1514
- Widget _buildSecuritySection(BuildContext context, NeoAgentController controller) {
1694
+ Widget _buildSecuritySection(
1695
+ BuildContext context,
1696
+ NeoAgentController controller,
1697
+ ) {
1515
1698
  return Card(
1516
1699
  child: Padding(
1517
1700
  padding: const EdgeInsets.all(20),
@@ -1661,6 +1844,32 @@ class _SettingsPanelState extends State<SettingsPanel> {
1661
1844
  }
1662
1845
  }
1663
1846
 
1847
+ Future<_LeaveAction?> _showLeaveDialog(BuildContext context) {
1848
+ return showDialog<_LeaveAction>(
1849
+ context: context,
1850
+ builder: (ctx) => AlertDialog(
1851
+ title: const Text('Unsaved changes'),
1852
+ content: const Text(
1853
+ 'You have unsaved settings. What would you like to do?',
1854
+ ),
1855
+ actions: <Widget>[
1856
+ TextButton(
1857
+ onPressed: () => Navigator.pop(ctx, _LeaveAction.cancel),
1858
+ child: const Text('Cancel'),
1859
+ ),
1860
+ TextButton(
1861
+ onPressed: () => Navigator.pop(ctx, _LeaveAction.discard),
1862
+ child: const Text('Discard'),
1863
+ ),
1864
+ FilledButton(
1865
+ onPressed: () => Navigator.pop(ctx, _LeaveAction.save),
1866
+ child: const Text('Save'),
1867
+ ),
1868
+ ],
1869
+ ),
1870
+ );
1871
+ }
1872
+
1664
1873
  List<_ModelPickerOption> _recordingTranscriptionOptions(String current) {
1665
1874
  const List<String> defaults = <String>['nova-3', 'nova-2-general'];
1666
1875
  final String normalizedCurrent = current.trim();
@@ -1690,14 +1899,20 @@ class _SettingsPanelState extends State<SettingsPanel> {
1690
1899
  Row(
1691
1900
  children: <Widget>[
1692
1901
  Icon(
1693
- passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
1902
+ passed
1903
+ ? Icons.check_circle_rounded
1904
+ : Icons.cancel_rounded,
1694
1905
  size: 15,
1695
- color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
1906
+ color: passed
1907
+ ? const Color(0xFF22C55E)
1908
+ : const Color(0xFFEF4444),
1696
1909
  ),
1697
1910
  const SizedBox(width: 6),
1698
1911
  Expanded(
1699
1912
  child: Text(
1700
- passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
1913
+ passed
1914
+ ? (detail.isNotEmpty ? detail : '$label: OK')
1915
+ : detail,
1701
1916
  style: TextStyle(
1702
1917
  fontSize: 13,
1703
1918
  color: passed ? null : const Color(0xFFEF4444),
@@ -1707,7 +1922,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
1707
1922
  ],
1708
1923
  )
1709
1924
  else if (note != null)
1710
- Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
1925
+ Text(
1926
+ note,
1927
+ style: TextStyle(
1928
+ fontSize: 13,
1929
+ color: _textSecondary,
1930
+ height: 1.4,
1931
+ ),
1932
+ ),
1711
1933
  ],
1712
1934
  ),
1713
1935
  ),
@@ -1844,7 +2066,9 @@ class _SmartPoolSummary extends StatelessWidget {
1844
2066
  style: TextStyle(fontSize: 12, color: _textMuted),
1845
2067
  )
1846
2068
  else
1847
- ...providers.take(12).map(
2069
+ ...providers
2070
+ .take(12)
2071
+ .map(
1848
2072
  (p) => Container(
1849
2073
  width: 8,
1850
2074
  height: 8,
@@ -1866,8 +2090,7 @@ class _SmartPoolSummary extends StatelessWidget {
1866
2090
  icon: const Icon(Icons.tune_rounded, size: 14),
1867
2091
  label: const Text('Manage'),
1868
2092
  style: OutlinedButton.styleFrom(
1869
- padding:
1870
- const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
2093
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1871
2094
  textStyle: const TextStyle(fontSize: 13),
1872
2095
  ),
1873
2096
  ),
@@ -1882,10 +2105,7 @@ class _SmartPoolSummary extends StatelessWidget {
1882
2105
  // ─────────────────────────────────────────────────────────────────────────────
1883
2106
 
1884
2107
  class _SmartPoolDialog extends StatefulWidget {
1885
- const _SmartPoolDialog({
1886
- required this.models,
1887
- required this.selectedIds,
1888
- });
2108
+ const _SmartPoolDialog({required this.models, required this.selectedIds});
1889
2109
 
1890
2110
  final List<ModelMeta> models;
1891
2111
  final Set<String> selectedIds;
@@ -1919,10 +2139,12 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
1919
2139
  if (_query.isNotEmpty) {
1920
2140
  final q = _query.toLowerCase();
1921
2141
  list = list
1922
- .where((m) =>
1923
- m.label.toLowerCase().contains(q) ||
1924
- m.id.toLowerCase().contains(q) ||
1925
- m.provider.toLowerCase().contains(q))
2142
+ .where(
2143
+ (m) =>
2144
+ m.label.toLowerCase().contains(q) ||
2145
+ m.id.toLowerCase().contains(q) ||
2146
+ m.provider.toLowerCase().contains(q),
2147
+ )
1926
2148
  .toList();
1927
2149
  }
1928
2150
  return list;
@@ -1940,9 +2162,7 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
1940
2162
  setState(() {
1941
2163
  final toRemove = filtered.map((m) => m.id).toSet();
1942
2164
  final remaining = _selected.difference(toRemove);
1943
- _selected = remaining.isNotEmpty
1944
- ? remaining
1945
- : <String>{_selected.first};
2165
+ _selected = remaining.isNotEmpty ? remaining : <String>{_selected.first};
1946
2166
  });
1947
2167
  }
1948
2168
 
@@ -1967,79 +2187,82 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
1967
2187
  final models = grouped[provider]!;
1968
2188
  final providerColor = _providerPickerColor(provider);
1969
2189
  final available = models.where((m) => m.available).toList();
1970
- final allGroupSelected = available.isNotEmpty &&
2190
+ final allGroupSelected =
2191
+ available.isNotEmpty &&
1971
2192
  available.every((m) => _selected.contains(m.id));
1972
2193
 
1973
- rows.add(Padding(
1974
- padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
1975
- child: Row(
1976
- children: <Widget>[
1977
- Container(
1978
- width: 6,
1979
- height: 6,
1980
- decoration: BoxDecoration(
1981
- color: providerColor,
1982
- shape: BoxShape.circle,
2194
+ rows.add(
2195
+ Padding(
2196
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
2197
+ child: Row(
2198
+ children: <Widget>[
2199
+ Container(
2200
+ width: 6,
2201
+ height: 6,
2202
+ decoration: BoxDecoration(
2203
+ color: providerColor,
2204
+ shape: BoxShape.circle,
2205
+ ),
1983
2206
  ),
1984
- ),
1985
- const SizedBox(width: 8),
1986
- Expanded(
1987
- child: Text(
1988
- _providerPickerLabel(provider).toUpperCase(),
1989
- style: TextStyle(
1990
- fontSize: 10.5,
1991
- fontWeight: FontWeight.w700,
1992
- color: _textMuted,
1993
- letterSpacing: 0.8,
2207
+ const SizedBox(width: 8),
2208
+ Expanded(
2209
+ child: Text(
2210
+ _providerPickerLabel(provider).toUpperCase(),
2211
+ style: TextStyle(
2212
+ fontSize: 10.5,
2213
+ fontWeight: FontWeight.w700,
2214
+ color: _textMuted,
2215
+ letterSpacing: 0.8,
2216
+ ),
1994
2217
  ),
1995
2218
  ),
1996
- ),
1997
- const SizedBox(width: 8),
1998
- GestureDetector(
1999
- onTap: available.isEmpty
2000
- ? null
2001
- : () {
2002
- setState(() {
2003
- if (allGroupSelected) {
2004
- final toRemove =
2005
- available.map((m) => m.id).toSet();
2006
- final remaining =
2007
- _selected.difference(toRemove);
2008
- _selected = remaining.isNotEmpty
2009
- ? remaining
2010
- : <String>{_selected.first};
2011
- } else {
2012
- for (final m in available) {
2013
- _selected.add(m.id);
2219
+ const SizedBox(width: 8),
2220
+ GestureDetector(
2221
+ onTap: available.isEmpty
2222
+ ? null
2223
+ : () {
2224
+ setState(() {
2225
+ if (allGroupSelected) {
2226
+ final toRemove = available.map((m) => m.id).toSet();
2227
+ final remaining = _selected.difference(toRemove);
2228
+ _selected = remaining.isNotEmpty
2229
+ ? remaining
2230
+ : <String>{_selected.first};
2231
+ } else {
2232
+ for (final m in available) {
2233
+ _selected.add(m.id);
2234
+ }
2014
2235
  }
2015
- }
2016
- });
2017
- },
2018
- child: Text(
2019
- allGroupSelected ? 'None' : 'All',
2020
- style: TextStyle(
2021
- fontSize: 11,
2022
- fontWeight: FontWeight.w600,
2023
- color: available.isEmpty ? _textMuted : _accent,
2236
+ });
2237
+ },
2238
+ child: Text(
2239
+ allGroupSelected ? 'None' : 'All',
2240
+ style: TextStyle(
2241
+ fontSize: 11,
2242
+ fontWeight: FontWeight.w600,
2243
+ color: available.isEmpty ? _textMuted : _accent,
2244
+ ),
2024
2245
  ),
2025
2246
  ),
2026
- ),
2027
- ],
2247
+ ],
2248
+ ),
2028
2249
  ),
2029
- ));
2250
+ );
2030
2251
 
2031
2252
  for (final model in models) {
2032
- rows.add(_SmartPoolRow(
2033
- model: model,
2034
- selected: _selected.contains(model.id),
2035
- onToggle: (val) => setState(() {
2036
- if (val) {
2037
- _selected.add(model.id);
2038
- } else if (_selected.length > 1) {
2039
- _selected.remove(model.id);
2040
- }
2041
- }),
2042
- ));
2253
+ rows.add(
2254
+ _SmartPoolRow(
2255
+ model: model,
2256
+ selected: _selected.contains(model.id),
2257
+ onToggle: (val) => setState(() {
2258
+ if (val) {
2259
+ _selected.add(model.id);
2260
+ } else if (_selected.length > 1) {
2261
+ _selected.remove(model.id);
2262
+ }
2263
+ }),
2264
+ ),
2265
+ );
2043
2266
  }
2044
2267
  }
2045
2268
 
@@ -2093,8 +2316,7 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
2093
2316
  style: IconButton.styleFrom(
2094
2317
  minimumSize: const Size(36, 36),
2095
2318
  padding: EdgeInsets.zero,
2096
- tapTargetSize:
2097
- MaterialTapTargetSize.shrinkWrap,
2319
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
2098
2320
  ),
2099
2321
  ),
2100
2322
  ],
@@ -2144,18 +2366,17 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
2144
2366
  : null,
2145
2367
  isDense: true,
2146
2368
  contentPadding: const EdgeInsets.symmetric(
2147
- vertical: 10),
2369
+ vertical: 10,
2370
+ ),
2148
2371
  filled: true,
2149
2372
  fillColor: _bgSecondary,
2150
2373
  border: OutlineInputBorder(
2151
2374
  borderRadius: BorderRadius.circular(12),
2152
- borderSide:
2153
- BorderSide(color: _border),
2375
+ borderSide: BorderSide(color: _border),
2154
2376
  ),
2155
2377
  enabledBorder: OutlineInputBorder(
2156
2378
  borderRadius: BorderRadius.circular(12),
2157
- borderSide:
2158
- BorderSide(color: _border),
2379
+ borderSide: BorderSide(color: _border),
2159
2380
  ),
2160
2381
  focusedBorder: OutlineInputBorder(
2161
2382
  borderRadius: BorderRadius.circular(12),
@@ -2170,7 +2391,8 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
2170
2391
  const SizedBox(width: 8),
2171
2392
  GestureDetector(
2172
2393
  onTap: () => setState(
2173
- () => _onlyAvailable = !_onlyAvailable),
2394
+ () => _onlyAvailable = !_onlyAvailable,
2395
+ ),
2174
2396
  child: AnimatedContainer(
2175
2397
  duration: const Duration(milliseconds: 150),
2176
2398
  padding: const EdgeInsets.symmetric(
@@ -2220,10 +2442,7 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
2220
2442
  const Spacer(),
2221
2443
  Text(
2222
2444
  '$selectedAvailableCount selected',
2223
- style: TextStyle(
2224
- fontSize: 12,
2225
- color: _textMuted,
2226
- ),
2445
+ style: TextStyle(fontSize: 12, color: _textMuted),
2227
2446
  ),
2228
2447
  ],
2229
2448
  ),
@@ -2254,8 +2473,7 @@ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
2254
2473
  ),
2255
2474
  )
2256
2475
  : ListView(
2257
- padding:
2258
- const EdgeInsets.only(top: 4, bottom: 8),
2476
+ padding: const EdgeInsets.only(top: 4, bottom: 8),
2259
2477
  shrinkWrap: true,
2260
2478
  children: rows,
2261
2479
  ),
@@ -2298,8 +2516,7 @@ class _SmartPoolRow extends StatelessWidget {
2298
2516
  child: InkWell(
2299
2517
  onTap: model.available ? () => onToggle(!selected) : null,
2300
2518
  child: Padding(
2301
- padding:
2302
- const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
2519
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
2303
2520
  child: Row(
2304
2521
  children: <Widget>[
2305
2522
  // Thin provider accent bar on the left
@@ -2307,8 +2524,7 @@ class _SmartPoolRow extends StatelessWidget {
2307
2524
  width: 3,
2308
2525
  height: 30,
2309
2526
  decoration: BoxDecoration(
2310
- color: color.withValues(
2311
- alpha: selected ? 0.85 : 0.28),
2527
+ color: color.withValues(alpha: selected ? 0.85 : 0.28),
2312
2528
  borderRadius: BorderRadius.circular(2),
2313
2529
  ),
2314
2530
  ),
@@ -2323,8 +2539,7 @@ class _SmartPoolRow extends StatelessWidget {
2323
2539
  : null,
2324
2540
  activeColor: _accent,
2325
2541
  side: BorderSide(color: _textMuted, width: 1.5),
2326
- materialTapTargetSize:
2327
- MaterialTapTargetSize.shrinkWrap,
2542
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
2328
2543
  visualDensity: VisualDensity.compact,
2329
2544
  ),
2330
2545
  ),
@@ -2338,8 +2553,7 @@ class _SmartPoolRow extends StatelessWidget {
2338
2553
  style: TextStyle(
2339
2554
  fontSize: 13,
2340
2555
  fontWeight: FontWeight.w500,
2341
- color:
2342
- selected ? _accentHover : _textPrimary,
2556
+ color: selected ? _accentHover : _textPrimary,
2343
2557
  ),
2344
2558
  maxLines: 1,
2345
2559
  overflow: TextOverflow.ellipsis,
@@ -2347,10 +2561,7 @@ class _SmartPoolRow extends StatelessWidget {
2347
2561
  if (model.purpose.isNotEmpty)
2348
2562
  Text(
2349
2563
  model.purpose,
2350
- style: TextStyle(
2351
- fontSize: 11,
2352
- color: _textMuted,
2353
- ),
2564
+ style: TextStyle(fontSize: 11, color: _textMuted),
2354
2565
  ),
2355
2566
  ],
2356
2567
  ),
@@ -2379,8 +2590,7 @@ class _PoolActionChip extends StatelessWidget {
2379
2590
  return GestureDetector(
2380
2591
  onTap: onTap,
2381
2592
  child: Container(
2382
- padding:
2383
- const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
2593
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
2384
2594
  decoration: BoxDecoration(
2385
2595
  color: _bgSecondary,
2386
2596
  borderRadius: BorderRadius.circular(8),