Move Inspector error tracking out of ErrorBadgeManager (#9923)
diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart
index fb2637e..8088054 100644
--- a/packages/devtools_app/lib/devtools_app.dart
+++ b/packages/devtools_app/lib/devtools_app.dart
@@ -27,6 +27,7 @@
 export 'src/screens/dtd/dtd_tools_controller.dart';
 export 'src/screens/dtd/dtd_tools_screen.dart';
 export 'src/screens/inspector/inspector_controller.dart';
+export 'src/screens/inspector/inspector_errors.dart';
 export 'src/screens/inspector/inspector_screen.dart';
 export 'src/screens/inspector/inspector_screen_body.dart';
 export 'src/screens/inspector/inspector_screen_controller.dart';
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart
index b9049b0..171231e 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart
@@ -31,6 +31,7 @@
 import '../../shared/diagnostics/diagnostics_node.dart';
 import '../../shared/diagnostics/inspector_service.dart';
 import '../../shared/diagnostics/primitives/instance_ref.dart';
+import '../../shared/framework/screen_controllers.dart';
 import '../../shared/globals.dart';
 import '../../shared/managers/notifications.dart';
 import '../../shared/primitives/query_parameters.dart';
@@ -38,6 +39,7 @@
 import '../../shared/utils/utils.dart';
 import 'inspector_data_models.dart';
 import 'inspector_screen.dart';
+import 'inspector_screen_controller.dart';
 import 'inspector_tree_controller.dart';
 
 final _log = Logger('inspector_controller');
@@ -149,6 +151,21 @@
     }
   }
 
+  /// Returns the [InspectorScreenController] when it is registered.
+  ///
+  /// [InspectorController] is sometimes constructed in unit tests without an
+  /// [InspectorScreenController] registered, so callers that only need to clear
+  /// errors on connect/reload should use this nullable accessor.
+  InspectorScreenController? get _inspectorScreenControllerOrNull {
+    final controllers = globals[ScreenControllers] as ScreenControllers?;
+    if (controllers == null) return null;
+    if (!controllers.isRegistered<InspectorScreenController>()) return null;
+    return controllers.lookup<InspectorScreenController>();
+  }
+
+  InspectorScreenController get _inspectorScreenController =>
+      screenControllers.lookup<InspectorScreenController>();
+
   void _handleConnectionStart() {
     // Clear any existing badge/errors for older errors that were collected.
     // Do this in a post frame callback so that we are not trying to clear the
@@ -157,7 +174,7 @@
     // TODO(kenz): When this method is called outside  createState(), this post
     // frame callback can be removed.
     WidgetsBinding.instance.addPostFrameCallback((_) {
-      serviceConnection.errorBadgeManager.clearErrors(InspectorScreen.id);
+      _inspectorScreenControllerOrNull?.clearErrors();
     });
   }
 
@@ -437,7 +454,7 @@
     }
 
     if (event.kind == EventKind.kIsolateReload) {
-      serviceConnection.errorBadgeManager.clearErrors(InspectorScreen.id);
+      _inspectorScreenControllerOrNull?.clearErrors();
       _receivedIsolateReloadEvent = true;
     }
   }
@@ -870,9 +887,7 @@
   void _updateSelectedErrorFromNode(InspectorTreeNode? node) {
     final inspectorRef = node?.diagnostic?.valueRef.id;
 
-    final errors = serviceConnection.errorBadgeManager
-        .erroredItemsForPage(InspectorScreen.id)
-        .value;
+    final errors = _inspectorScreenController.inspectorErrors.value;
 
     // Check whether the node that was just selected has any errors associated
     // with it.
@@ -888,10 +903,7 @@
     if (errorIndex != null) {
       // Marking an error as read will automatically update the badge count to
       // reflect the remaining unread errors.
-      serviceConnection.errorBadgeManager.markErrorAsRead(
-        InspectorScreen.id,
-        errors[inspectorRef!]!,
-      );
+      _inspectorScreenController.markErrorAsRead(errors[inspectorRef!]!);
     }
   }
 
@@ -899,9 +911,7 @@
   void selectErrorByIndex(int index) {
     _selectedErrorIndex.value = index;
 
-    final errors = serviceConnection.errorBadgeManager
-        .erroredItemsForPage(InspectorScreen.id)
-        .value;
+    final errors = _inspectorScreenController.inspectorErrors.value;
 
     unawaited(
       updateSelectionFromService(inspectorRef: errors.keys.elementAt(index)),
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_errors.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_errors.dart
new file mode 100644
index 0000000..eb12e8e
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_errors.dart
@@ -0,0 +1,15 @@
+// Copyright 2024 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import '../../shared/managers/error_badge_manager.dart';
+
+/// An error associated with a specific widget that can be inspected in the
+/// Inspector screen.
+class InspectableWidgetError extends DevToolsError {
+  InspectableWidgetError(super.errorMessage, super.id, {super.read});
+
+  @override
+  InspectableWidgetError asRead() =>
+      InspectableWidgetError(errorMessage, id, read: true);
+}
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart
index f61c22f..67403c5 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart
@@ -21,7 +21,9 @@
 import '../../shared/utils/utils.dart';
 import 'inspector_controller.dart';
 import 'inspector_controls.dart';
+import 'inspector_errors.dart';
 import 'inspector_screen.dart';
+import 'inspector_screen_controller.dart';
 import 'inspector_tree_controller.dart';
 import 'widget_details.dart';
 
@@ -149,8 +151,9 @@
               ),
               Expanded(
                 child: ValueListenableBuilder(
-                  valueListenable: serviceConnection.errorBadgeManager
-                      .erroredItemsForPage(InspectorScreen.id),
+                  valueListenable: screenControllers
+                      .lookup<InspectorScreenController>()
+                      .inspectorErrors,
                   builder: (_, LinkedHashMap<String, DevToolsError> errors, _) {
                     final inspectableErrors =
                         errors.map(
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart
index 5a403db..4d33da8 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart
@@ -2,11 +2,26 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
+import 'dart:collection';
+
+import 'package:collection/collection.dart' show IterableExtension;
+import 'package:devtools_app_shared/service.dart';
+import 'package:devtools_app_shared/utils.dart';
+import 'package:flutter/foundation.dart';
+import 'package:vm_service/vm_service.dart';
+
+import '../../service/vm_service_wrapper.dart';
 import '../../shared/analytics/metrics.dart';
 import '../../shared/console/primitives/simple_items.dart';
+import '../../shared/diagnostics/diagnostics_node.dart';
 import '../../shared/framework/screen.dart';
 import '../../shared/framework/screen_controllers.dart';
+import '../../shared/globals.dart';
+import '../../shared/managers/error_badge_manager.dart';
+import '../../shared/primitives/query_parameters.dart';
 import 'inspector_controller.dart';
+import 'inspector_errors.dart';
+import 'inspector_screen.dart';
 import 'inspector_tree_controller.dart';
 
 /// Screen controller for the Inspector screen.
@@ -19,16 +34,36 @@
 /// `init` method is called lazily upon the first controller access from
 /// `screenControllers`. The `dispose` method is called by `screenControllers`
 /// when DevTools is destroying a set of DevTools screen controllers.
-class InspectorScreenController extends DevToolsScreenController {
+class InspectorScreenController extends DevToolsScreenController
+    with AutoDisposeControllerMixin {
   @override
   final screenId = ScreenMetaData.inspector.id;
 
   late InspectorController inspectorController;
   late InspectorTreeController inspectorTreeController;
 
+  /// Stores the inspector-specific errors keyed by inspector reference ID.
+  final _activeInspectorErrors =
+      ValueNotifier<LinkedHashMap<String, DevToolsError>>(
+        LinkedHashMap<String, DevToolsError>(),
+      );
+
+  /// The errors currently tracked for the inspector screen.
+  ValueListenable<LinkedHashMap<String, DevToolsError>> get inspectorErrors =>
+      _activeInspectorErrors;
+
+  /// The count of unread inspector errors (used for the badge).
+  ValueListenable<int> get inspectorErrorCount => serviceConnection
+      .errorBadgeManager
+      .errorCountNotifier(InspectorScreen.id);
+
   @override
   void init() {
     super.init();
+    // Inspector owns unread state for its badge; scaffold tab switches must not
+    // clear it. See https://github.com/flutter/devtools/pull/9805.
+    serviceConnection.errorBadgeManager.manageErrorCount(InspectorScreen.id);
+
     inspectorTreeController = InspectorTreeController(
       gaId: InspectorScreenMetrics.summaryTreeGaId,
     );
@@ -36,10 +71,127 @@
       inspectorTree: inspectorTreeController,
       treeType: FlutterTreeType.widget,
     );
+
+    // Listen for Flutter extension events to extract inspector-specific errors.
+    // Match other screen controllers: attach now if connected, and on connect.
+    addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () {
+      if (serviceConnection.serviceManager.connectedState.value.connected) {
+        _handleConnectionStart(serviceConnection.serviceManager.service!);
+      }
+    });
+    if (serviceConnection.serviceManager.connectedAppInitialized) {
+      _handleConnectionStart(serviceConnection.serviceManager.service!);
+    }
+  }
+
+  void _handleConnectionStart(VmServiceWrapper service) {
+    autoDisposeStreamSubscription(
+      service.onExtensionEventWithHistorySafe.listen(_handleExtensionEvent),
+    );
+  }
+
+  void _handleExtensionEvent(Event e) {
+    if (e.extensionKind == FlutterEvent.error) {
+      final inspectableError = _extractInspectableError(e);
+      if (inspectableError != null) {
+        appendError(inspectableError);
+      }
+    }
+  }
+
+  InspectableWidgetError? _extractInspectableError(Event error) {
+    final extensionData = error.extensionData;
+    if (extensionData == null) return null;
+
+    final node = RemoteDiagnosticsNode(extensionData.data, null, false, null);
+
+    final errorSummaryNode = node.inlineProperties.firstWhereOrNull(
+      (p) => p.type == 'ErrorSummary',
+    );
+    final errorMessage = errorSummaryNode?.description;
+    if (errorMessage == null) {
+      return null;
+    }
+
+    final devToolsUrlNode = node.inlineProperties.firstWhereOrNull(
+      (p) =>
+          p.type == 'DevToolsDeepLinkProperty' &&
+          p.getStringMember('value') != null,
+    );
+    if (devToolsUrlNode == null) {
+      return null;
+    }
+
+    final queryParams = DevToolsQueryParams.fromUrl(
+      devToolsUrlNode.getStringMember('value')!,
+    );
+    final inspectorRef = queryParams.inspectorRef ?? '';
+
+    return InspectableWidgetError(errorMessage, inspectorRef);
+  }
+
+  /// Appends an error to the inspector's active errors and updates the badge
+  /// count.
+  void appendError(DevToolsError error) {
+    final errors = _activeInspectorErrors;
+    final previousError = errors.value[error.id];
+
+    // Build a new map with the new error. Adding to the existing map
+    // won't cause the ValueNotifier to fire (and it's not permitted to call
+    // notifyListeners() directly).
+    final newValue = LinkedHashMap<String, DevToolsError>.of(errors.value);
+    newValue[error.id] = error;
+    errors.value = newValue;
+
+    if (previousError == null) {
+      if (!error.read) {
+        _incrementUnreadCount();
+      }
+      return;
+    }
+
+    if (previousError.read && !error.read) {
+      _incrementUnreadCount();
+    } else if (!previousError.read && error.read) {
+      _decrementUnreadCount();
+    }
+  }
+
+  /// Clears all inspector errors and resets the badge count.
+  void clearErrors() {
+    _activeInspectorErrors.value = LinkedHashMap<String, DevToolsError>();
+    serviceConnection.errorBadgeManager.resetErrorCount(InspectorScreen.id);
+  }
+
+  /// Marks an error as read and decrements the unread count.
+  void markErrorAsRead(DevToolsError error) {
+    final errors = _activeInspectorErrors;
+
+    // If this error doesn't exist anymore or is already read, nothing to do.
+    final currentError = errors.value[error.id];
+    if (currentError == null || currentError.read) {
+      return;
+    }
+
+    // Otherwise, replace the map with a new one that has the error marked
+    // as read.
+    final newValue = LinkedHashMap<String, DevToolsError>.of(errors.value);
+    newValue[error.id] = currentError.asRead();
+    errors.value = newValue;
+    _decrementUnreadCount();
+  }
+
+  void _incrementUnreadCount() {
+    serviceConnection.errorBadgeManager.incrementBadgeCount(InspectorScreen.id);
+  }
+
+  void _decrementUnreadCount() {
+    serviceConnection.errorBadgeManager.decrementBadgeCount(InspectorScreen.id);
   }
 
   @override
   void dispose() {
+    _activeInspectorErrors.dispose();
     inspectorTreeController.dispose();
     inspectorController.dispose();
     super.dispose();
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart
index 957364a..fee0b94 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart
@@ -30,6 +30,7 @@
 import '../../shared/ui/utils.dart';
 import '../../shared/utils/utils.dart';
 import 'inspector_controller.dart';
+import 'inspector_errors.dart';
 
 final _log = Logger('inspector_tree_controller');
 
diff --git a/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart b/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart
index 427254c..7baade8 100644
--- a/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart
+++ b/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart
@@ -51,6 +51,14 @@
     controllers[T] = _LazyController<T>(creator: controllerCreator);
   }
 
+  /// Whether a controller of type [T] has been registered for the active mode.
+  bool isRegistered<T>() {
+    final controllers = offlineDataController.showingOfflineData.value
+        ? offlineControllers
+        : this.controllers;
+    return controllers.containsKey(T);
+  }
+
   /// Returns the active screen controller of type [T].
   ///
   /// When DevTools is showing offline data, the offline screen controller will
diff --git a/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart b/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart
index 10a12fb..2689cd4 100644
--- a/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart
+++ b/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart
@@ -3,9 +3,7 @@
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
 import 'dart:async';
-import 'dart:collection';
 
-import 'package:collection/collection.dart' show IterableExtension;
 import 'package:devtools_app_shared/service.dart';
 import 'package:devtools_app_shared/utils.dart';
 import 'package:flutter/foundation.dart';
@@ -17,26 +15,29 @@
 import '../../screens/performance/performance_screen.dart';
 import '../../service/service_extensions.dart' as extensions;
 import '../../service/vm_service_wrapper.dart';
-import '../diagnostics/diagnostics_node.dart';
 import '../globals.dart';
 import '../primitives/listenable.dart';
-import '../primitives/query_parameters.dart';
 
+/// Manages error badge counts for DevTools screen tabs.
+///
+/// This is a generic counter that tracks unread error counts per screen.
+/// Screen-specific error tracking logic (e.g., detailed error objects for the
+/// Inspector screen) should live in the respective screen controllers.
 class ErrorBadgeManager extends DisposableController
     with AutoDisposeControllerMixin {
-  // TODO(https://github.com/flutter/devtools/issues/9105): Separate out
-  // Inspector-specific logic from this file.
   final _activeErrorCounts = <String, ValueNotifier<int>>{
     InspectorScreen.id: ValueNotifier<int>(0),
     PerformanceScreen.id: ValueNotifier<int>(0),
     NetworkScreen.id: ValueNotifier<int>(0),
   };
-  final _activeErrors =
-      <String, ValueNotifier<LinkedHashMap<String, DevToolsError>>>{
-        InspectorScreen.id: ValueNotifier<LinkedHashMap<String, DevToolsError>>(
-          LinkedHashMap<String, DevToolsError>(),
-        ),
-      };
+
+  /// Screen ids whose unread badge count is owned by the screen controller.
+  ///
+  /// For these screens, [clearErrorCount] is a no-op so navigating to the tab
+  /// (see scaffold) does not drop the unread badge. Controllers should update
+  /// the count via [incrementBadgeCount], [decrementBadgeCount], and
+  /// [resetErrorCount] instead.
+  final _managedErrorCounts = <String>{};
 
   void vmServiceOpened(VmServiceWrapper service) {
     // Ensure structured errors are enabled.
@@ -63,100 +64,29 @@
   void _handleExtensionEvent(Event e) {
     if (e.extensionKind == FlutterEvent.error) {
       incrementBadgeCount(LoggingScreen.id);
-
-      final inspectableError = _extractInspectableError(e);
-      if (inspectableError != null) {
-        appendError(InspectorScreen.id, inspectableError);
-      }
     }
   }
 
-  InspectableWidgetError? _extractInspectableError(Event error) {
-    // TODO(https://github.com/flutter/devtools/issues/9105): Switch to using
-    // the inspectorService from the serviceManager once Jacob's change to add
-    // it lands.
-    final node = RemoteDiagnosticsNode(
-      error.extensionData!.data,
-      null,
-      false,
-      null,
-    );
-
-    final errorSummaryNode = node.inlineProperties.firstWhereOrNull(
-      (p) => p.type == 'ErrorSummary',
-    );
-    final errorMessage = errorSummaryNode?.description;
-    if (errorMessage == null) {
-      return null;
-    }
-
-    final devToolsUrlNode = node.inlineProperties.firstWhereOrNull(
-      (p) =>
-          p.type == 'DevToolsDeepLinkProperty' &&
-          p.getStringMember('value') != null,
-    );
-    if (devToolsUrlNode == null) {
-      return null;
-    }
-
-    final queryParams = DevToolsQueryParams.fromUrl(
-      devToolsUrlNode.getStringMember('value')!,
-    );
-    final inspectorRef = queryParams.inspectorRef ?? '';
-
-    return InspectableWidgetError(errorMessage, inspectorRef);
-  }
-
   void _handleStdErr(Event _) {
     incrementBadgeCount(LoggingScreen.id);
   }
 
+  /// Marks [screenId] as managing its own unread badge count.
+  ///
+  /// After this is called, [clearErrorCount] will not reset the badge for
+  /// [screenId] (preserving unread state across tab switches).
+  void manageErrorCount(String screenId) {
+    _managedErrorCounts.add(screenId);
+  }
+
   void incrementBadgeCount(String screenId) {
-    if (_activeErrors.containsKey(screenId)) {
-      return;
-    }
-
     final notifier = _errorCountNotifier(screenId);
     if (notifier == null) return;
 
-    final currentCount = notifier.value;
-    notifier.value = currentCount + 1;
-  }
-
-  void appendError(String screenId, DevToolsError error) {
-    final errors = _activeErrors[screenId];
-    if (errors == null) return;
-
-    final previousError = errors.value[error.id];
-
-    // Build a new map with the new error. Adding to the existing map
-    // won't cause the ValueNotifier to fire (and it's not permitted to call
-    // notifyListeners() directly).
-    final newValue = LinkedHashMap<String, DevToolsError>.of(errors.value);
-    newValue[error.id] = error;
-    errors.value = newValue;
-
-    if (previousError == null) {
-      if (!error.read) {
-        _incrementUnreadCount(screenId);
-      }
-      return;
-    }
-
-    if (previousError.read && !error.read) {
-      _incrementUnreadCount(screenId);
-    } else if (!previousError.read && error.read) {
-      _decrementUnreadCount(screenId);
-    }
-  }
-
-  void _incrementUnreadCount(String screenId) {
-    final notifier = _errorCountNotifier(screenId);
-    if (notifier == null) return;
     notifier.value = notifier.value + 1;
   }
 
-  void _decrementUnreadCount(String screenId) {
+  void decrementBadgeCount(String screenId) {
     final notifier = _errorCountNotifier(screenId);
     if (notifier == null) return;
     if (notifier.value == 0) return;
@@ -167,55 +97,23 @@
     return _errorCountNotifier(screenId) ?? const FixedValueListenable<int>(0);
   }
 
-  ValueListenable<LinkedHashMap<String, DevToolsError>> erroredItemsForPage(
-    String screenId,
-  ) {
-    return _activeErrors[screenId] ??
-        FixedValueListenable<LinkedHashMap<String, DevToolsError>>(
-          LinkedHashMap<String, DevToolsError>(),
-        );
-  }
-
   ValueNotifier<int>? _errorCountNotifier(String screenId) {
     return _activeErrorCounts[screenId];
   }
 
+  /// Clears the badge count for [screenId], unless it is [manageErrorCount]d.
   void clearErrorCount(String screenId) {
-    if (_activeErrors.containsKey(screenId)) {
-      return;
-    }
+    if (_managedErrorCounts.contains(screenId)) return;
     _activeErrorCounts[screenId]?.value = 0;
   }
 
-  void clearErrors(String screenId) {
-    if (!_activeErrors.containsKey(screenId)) {
-      clearErrorCount(screenId);
-      return;
-    }
-
-    _activeErrors[screenId]?.value = LinkedHashMap<String, DevToolsError>();
+  /// Unconditionally resets the badge count for [screenId].
+  ///
+  /// Use from screen controllers that call [manageErrorCount] when they need
+  /// to clear their own unread state (e.g. on hot restart).
+  void resetErrorCount(String screenId) {
     _activeErrorCounts[screenId]?.value = 0;
   }
-
-  void markErrorAsRead(String screenId, DevToolsError error) {
-    final errors = _activeErrors[screenId];
-    if (errors == null) return;
-
-    // If this error doesn't exist anymore or is already read, nothing to do.
-    if (errors.value[error.id]?.read ?? true) {
-      return;
-    }
-
-    // Otherwise, replace the map with a new one that has the error marked
-    // as read.
-    errors.value = LinkedHashMap<String, DevToolsError>.fromEntries(
-      errors.value.entries.map((e) {
-        if (e.value != error) return e;
-        return MapEntry(e.key, e.value.asRead());
-      }),
-    );
-    _decrementUnreadCount(screenId);
-  }
 }
 
 class DevToolsError {
@@ -227,11 +125,3 @@
 
   DevToolsError asRead() => DevToolsError(errorMessage, id, read: true);
 }
-
-class InspectableWidgetError extends DevToolsError {
-  InspectableWidgetError(super.errorMessage, super.id, {super.read});
-
-  @override
-  InspectableWidgetError asRead() =>
-      InspectableWidgetError(errorMessage, id, read: true);
-}
diff --git a/packages/devtools_app/test/screens/inspector/inspector_screen_controller_test.dart b/packages/devtools_app/test/screens/inspector/inspector_screen_controller_test.dart
new file mode 100644
index 0000000..02fb69d
--- /dev/null
+++ b/packages/devtools_app/test/screens/inspector/inspector_screen_controller_test.dart
@@ -0,0 +1,75 @@
+// Copyright 2025 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_app_shared/utils.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  late ServiceConnectionManager serviceConnectionManager;
+  late InspectorScreenController controller;
+
+  setUp(() {
+    serviceConnectionManager = ServiceConnectionManager();
+    setGlobal(ServiceConnectionManager, serviceConnectionManager);
+    controller = InspectorScreenController();
+    // Mirror the badge-ownership registration performed in [init] without
+    // constructing the full Inspector tree controllers.
+    serviceConnection.errorBadgeManager.manageErrorCount(InspectorScreen.id);
+  });
+
+  int unreadBadgeCount() => serviceConnection.errorBadgeManager
+      .errorCountNotifier(InspectorScreen.id)
+      .value;
+
+  test('appendError stores the error and increments the badge', () {
+    controller.appendError(InspectableWidgetError('Overflow', 'ref-1'));
+
+    expect(controller.inspectorErrors.value.length, equals(1));
+    expect(controller.inspectorErrors.value['ref-1']!.errorMessage, 'Overflow');
+    expect(unreadBadgeCount(), equals(1));
+  });
+
+  test('appendError does not double-count the same error id', () {
+    controller.appendError(InspectableWidgetError('Overflow', 'ref-1'));
+    controller.appendError(InspectableWidgetError('Overflow again', 'ref-1'));
+
+    expect(controller.inspectorErrors.value.length, equals(1));
+    expect(unreadBadgeCount(), equals(1));
+  });
+
+  test('markErrorAsRead decrements the badge', () {
+    final error = InspectableWidgetError('Overflow', 'ref-1');
+    controller.appendError(error);
+    controller.markErrorAsRead(error);
+
+    expect(controller.inspectorErrors.value['ref-1']!.read, isTrue);
+    expect(unreadBadgeCount(), equals(0));
+  });
+
+  test('clearErrors resets errors and badge', () {
+    controller.appendError(InspectableWidgetError('Overflow', 'ref-1'));
+    controller.appendError(InspectableWidgetError('Null check', 'ref-2'));
+    expect(unreadBadgeCount(), equals(2));
+
+    controller.clearErrors();
+
+    expect(controller.inspectorErrors.value, isEmpty);
+    expect(unreadBadgeCount(), equals(0));
+  });
+
+  test(
+    'scaffold-style clearErrorCount does not drop inspector unread badge',
+    () {
+      controller.appendError(InspectableWidgetError('Overflow', 'ref-1'));
+      expect(unreadBadgeCount(), equals(1));
+
+      // Simulates DevToolsScaffold clearing the badge on tab navigation.
+      serviceConnection.errorBadgeManager.clearErrorCount(InspectorScreen.id);
+
+      expect(unreadBadgeCount(), equals(1));
+      expect(controller.inspectorErrors.value.length, equals(1));
+    },
+  );
+}
diff --git a/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart b/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart
index e3c12b6..bbd3d15 100644
--- a/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart
+++ b/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart
@@ -13,7 +13,11 @@
 import 'package:devtools_app/src/shared/managers/error_badge_manager.dart';
 import 'package:flutter_test/flutter_test.dart';
 
-final screensWithCountOnly = [PerformanceScreen.id, NetworkScreen.id];
+final screensWithBadgeCounts = [
+  InspectorScreen.id,
+  PerformanceScreen.id,
+  NetworkScreen.id,
+];
 
 final allScreenIds = [
   InspectorScreen.id,
@@ -30,9 +34,6 @@
   late ErrorBadgeManager errorBadgeManager;
 
   group('ErrorBadgeManager', () {
-    int getActiveErrorCount(String screenId) =>
-        errorBadgeManager.erroredItemsForPage(screenId).value.entries.length;
-
     setUp(() {
       errorBadgeManager = ErrorBadgeManager();
     });
@@ -51,7 +52,7 @@
       allScreenIds.forEach(errorBadgeManager.incrementBadgeCount);
 
       for (final id in allScreenIds) {
-        if (screensWithCountOnly.contains(id)) {
+        if (screensWithBadgeCounts.contains(id)) {
           expect(errorBadgeManager.errorCountNotifier(id).value, equals(1));
         } else {
           expect(errorBadgeManager.errorCountNotifier(id).value, equals(0));
@@ -59,11 +60,36 @@
       }
     });
 
+    test('decrementBadgeCount decrements supported tabs', () {
+      // First increment
+      allScreenIds.forEach(errorBadgeManager.incrementBadgeCount);
+
+      for (final id in screensWithBadgeCounts) {
+        expect(errorBadgeManager.errorCountNotifier(id).value, equals(1));
+      }
+
+      // Then decrement
+      allScreenIds.forEach(errorBadgeManager.decrementBadgeCount);
+
+      for (final id in screensWithBadgeCounts) {
+        expect(errorBadgeManager.errorCountNotifier(id).value, equals(0));
+      }
+    });
+
+    test('decrementBadgeCount does not go below zero', () {
+      // Decrement without any prior increment
+      errorBadgeManager.decrementBadgeCount(InspectorScreen.id);
+      expect(
+        errorBadgeManager.errorCountNotifier(InspectorScreen.id).value,
+        equals(0),
+      );
+    });
+
     test('clearErrorCount resets counts', () {
       allScreenIds.forEach(errorBadgeManager.incrementBadgeCount);
 
       for (final id in allScreenIds) {
-        if (screensWithCountOnly.contains(id)) {
+        if (screensWithBadgeCounts.contains(id)) {
           expect(errorBadgeManager.errorCountNotifier(id).value, equals(1));
         } else {
           expect(errorBadgeManager.errorCountNotifier(id).value, equals(0));
@@ -77,43 +103,23 @@
       }
     });
 
-    // TODO(https://github.com/flutter/devtools/issues/9105): This logic should
-    // be moved to the inspector.
-    test('appendError works for inspector screen only', () {
-      for (final id in allScreenIds) {
-        errorBadgeManager.appendError(id, DevToolsError('An error', id));
-      }
-
-      for (final id in allScreenIds) {
-        if (id == InspectorScreen.id) {
-          expect(getActiveErrorCount(id), equals(1));
-        } else {
-          expect(getActiveErrorCount(id), equals(0));
-        }
-      }
-    });
-
-    test('clearErrors resets counts and removes errors', () {
-      expect(getActiveErrorCount(InspectorScreen.id), equals(0));
-      expect(
-        errorBadgeManager.errorCountNotifier(InspectorScreen.id).value,
-        equals(0),
-      );
-
-      errorBadgeManager.appendError(
-        InspectorScreen.id,
-        DevToolsError('An error', InspectorScreen.id),
-      );
-
-      expect(getActiveErrorCount(InspectorScreen.id), equals(1));
+    test('clearErrorCount is a no-op for manageErrorCount screens', () {
+      errorBadgeManager.manageErrorCount(InspectorScreen.id);
+      errorBadgeManager.incrementBadgeCount(InspectorScreen.id);
       expect(
         errorBadgeManager.errorCountNotifier(InspectorScreen.id).value,
         equals(1),
       );
 
-      errorBadgeManager.clearErrors(InspectorScreen.id);
+      // Simulates scaffold clearing the badge on tab navigation.
+      errorBadgeManager.clearErrorCount(InspectorScreen.id);
+      expect(
+        errorBadgeManager.errorCountNotifier(InspectorScreen.id).value,
+        equals(1),
+      );
 
-      expect(getActiveErrorCount(InspectorScreen.id), equals(0));
+      // Controllers that own unread state can still reset explicitly.
+      errorBadgeManager.resetErrorCount(InspectorScreen.id);
       expect(
         errorBadgeManager.errorCountNotifier(InspectorScreen.id).value,
         equals(0),
diff --git a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart
index 1b4027f..5b948b5 100644
--- a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart
+++ b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart
@@ -3,7 +3,6 @@
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
 import 'dart:async';
-import 'dart:collection';
 
 import 'package:devtools_app/devtools_app.dart';
 import 'package:devtools_app_shared/service.dart';
@@ -40,9 +39,6 @@
     );
     for (final screen in ScreenMetaData.values) {
       final screenId = screen.id;
-      when(errorBadgeManager.erroredItemsForPage(screenId)).thenReturn(
-        FixedValueListenable(LinkedHashMap<String, DevToolsError>()),
-      );
       when(
         errorBadgeManager.errorCountNotifier(screenId),
       ).thenReturn(ValueNotifier<int>(0));