[A11y] Display a11y issues in the devtool (#10038)

* Add issues

* Update accessibility_controller.dart
diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
index df02423..679bdcc 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
@@ -187,7 +187,9 @@
       );
 
       final response = await serviceConnection.serviceManager
-          .callServiceExtensionOnMainIsolate(registrations.getSemanticsTree);
+          .callServiceExtensionOnMainIsolate(
+            registrations.getIssuesAndSemanticsTree,
+          );
 
       if (disposed) return;
 
@@ -203,13 +205,34 @@
         );
       }
 
+      final issuesMap = <String, List<AccessibilityIssue>>{};
+      final rawIssues = json?['issues'];
+      if (rawIssues is List<Object?>) {
+        for (final rawIssue in rawIssues) {
+          if (rawIssue is! Map) continue;
+          final nodeId = rawIssue['nodeId']?.toString();
+          if (nodeId != null) {
+            issuesMap
+                .putIfAbsent(nodeId, () => [])
+                .add(
+                  AccessibilityIssue(
+                    rule: rawIssue['rule']?.toString() ?? '',
+                    description: rawIssue['description']?.toString() ?? '',
+                  ),
+                );
+          }
+        }
+      }
+
       final roots = <SemanticsNodeModel>[];
       if (rawData is Map<String, dynamic>) {
         if (rawData.isNotEmpty) {
           final rootId = rawData.containsKey('0')
               ? '0'
               : rawData.keys.first.toString();
-          roots.add(_buildTreeFromNodesMap(rootId, rawData, <String>{}));
+          roots.add(
+            _buildTreeFromNodesMap(rootId, rawData, <String>{}, issuesMap),
+          );
         }
       }
 
@@ -239,6 +262,7 @@
     String nodeId,
     Map<String, dynamic> nodesMap,
     Set<String> visited,
+    Map<String, List<AccessibilityIssue>> issuesMap,
   ) {
     if (!visited.add(nodeId)) {
       return SemanticsNodeModel(id: nodeId);
@@ -247,7 +271,8 @@
     final json =
         (nodesMap[nodeId] as Map<String, dynamic>?) ??
         <String, dynamic>{'id': nodeId};
-    final node = _parseSemanticsNode(json);
+    final nodeIssues = issuesMap[nodeId] ?? const [];
+    final node = _parseSemanticsNode(json, nodeIssues);
 
     final childIds =
         (json['childrenInTraversalOrder'] as List?)
@@ -260,7 +285,12 @@
 
     for (final childId in childIds) {
       if (nodesMap.containsKey(childId)) {
-        final childNode = _buildTreeFromNodesMap(childId, nodesMap, visited);
+        final childNode = _buildTreeFromNodesMap(
+          childId,
+          nodesMap,
+          visited,
+          issuesMap,
+        );
         node.addChild(childNode);
       }
     }
@@ -268,7 +298,10 @@
     return node;
   }
 
-  SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
+  SemanticsNodeModel _parseSemanticsNode(
+    Map<String, dynamic> json, [
+    List<AccessibilityIssue> issues = const [],
+  ]) {
     final rawFlags = json['flags'] as List<Object?>?;
     final flags = SemanticsNodeModel.parseFlags(rawFlags);
     final rect = SemanticsNodeModel.parseRect(json['rect']);
@@ -280,6 +313,7 @@
       hint: json['hint']?.toString() ?? '',
       rect: rect,
       flags: flags,
+      issues: issues,
       widgetName: json['widgetName']?.toString() ?? '',
     );
   }
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart
index 7202781..72deee2 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart
@@ -80,6 +80,10 @@
             const SizedBox(height: denseSpacing),
             const Divider(),
             const SizedBox(height: denseSpacing),
+            if (node.issues.isNotEmpty) ...[
+              _AccessibilityIssuesSection(issues: node.issues),
+              const SizedBox(height: defaultSpacing),
+            ],
             _NodeDetailSection(
               title: _labelTitle,
               description: _labelDescription,
@@ -248,3 +252,93 @@
     );
   }
 }
+
+/// A section displaying all [AccessibilityIssue]s detected on the selected node.
+class _AccessibilityIssuesSection extends StatelessWidget {
+  const _AccessibilityIssuesSection({required this.issues});
+
+  final List<AccessibilityIssue> issues;
+
+  static const _issuesTitle = 'Accessibility Issues';
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final errorColor = theme.colorScheme.error;
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Row(
+          children: [
+            Icon(
+              Icons.warning_amber_rounded,
+              size: defaultIconSize,
+              color: errorColor,
+            ),
+            const SizedBox(width: denseSpacing),
+            Text(
+              '$_issuesTitle (${issues.length})',
+              style: theme.boldTextStyle.copyWith(color: errorColor),
+            ),
+          ],
+        ),
+        const SizedBox(height: denseSpacing),
+        for (var i = 0; i < issues.length; i++) ...[
+          if (i > 0) const SizedBox(height: denseSpacing),
+          _AccessibilityIssueBox(issue: issues[i]),
+        ],
+      ],
+    );
+  }
+}
+
+/// Displays an accessibility issue detected on a semantics node.
+class _AccessibilityIssueBox extends StatelessWidget {
+  const _AccessibilityIssueBox({required this.issue});
+
+  final AccessibilityIssue issue;
+
+  static const _boxBackgroundAlpha = 0.05;
+  static const _boxBorderAlpha = 0.5;
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    final errorColor = theme.colorScheme.error;
+
+    return Container(
+      width: double.infinity,
+      padding: const EdgeInsets.all(defaultSpacing),
+      decoration: BoxDecoration(
+        color: errorColor.withValues(alpha: _boxBackgroundAlpha),
+        borderRadius: defaultBorderRadius,
+        border: Border.all(
+          color: errorColor.withValues(alpha: _boxBorderAlpha),
+        ),
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Row(
+            children: [
+              Icon(
+                Icons.warning_amber_rounded,
+                size: defaultIconSize,
+                color: errorColor,
+              ),
+              const SizedBox(width: denseSpacing),
+              Expanded(
+                child: Text(
+                  issue.rule,
+                  style: theme.boldTextStyle.copyWith(color: errorColor),
+                ),
+              ),
+            ],
+          ),
+          const SizedBox(height: denseSpacing),
+          SelectableText(issue.description, style: theme.fixedFontStyle),
+        ],
+      ),
+    );
+  }
+}
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
index f03fe12..9600d12 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
@@ -9,6 +9,17 @@
 
 import '../../shared/primitives/trees.dart';
 
+/// Represents an accessibility issue detected on a semantics node.
+class AccessibilityIssue {
+  const AccessibilityIssue({required this.rule, required this.description});
+
+  /// The rule name that was violated.
+  final String rule;
+
+  /// A human-readable description of the issue.
+  final String description;
+}
+
 /// Represents a node in the accessibility semantics tree.
 class SemanticsNodeModel extends TreeNode<SemanticsNodeModel> {
   SemanticsNodeModel({
@@ -18,6 +29,7 @@
     this.hint = '',
     this.rect,
     this.flags = const <SemanticsFlag>{},
+    this.issues = const <AccessibilityIssue>[],
     this.widgetName = '',
   });
 
@@ -39,6 +51,9 @@
   /// Semantic flags active on this node.
   final Set<SemanticsFlag> flags;
 
+  /// The accessibility issues detected for this node.
+  final List<AccessibilityIssue> issues;
+
   /// The name of the Flutter widget that produced this node, if available.
   final String widgetName;
 
@@ -107,6 +122,7 @@
       hint: hint,
       rect: rect,
       flags: flags,
+      issues: issues,
       widgetName: widgetName,
     );
   }
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
index 3974b15..97488c3 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
@@ -230,6 +230,14 @@
                         ),
                       ),
                     ],
+                    if (node.issues.isNotEmpty) ...[
+                      const SizedBox(width: denseSpacing),
+                      Icon(
+                        Icons.warning_amber_rounded,
+                        size: defaultIconSize,
+                        color: colorScheme.error,
+                      ),
+                    ],
                   ],
                 ),
               ),
diff --git a/packages/devtools_app/lib/src/service/service_registrations.dart b/packages/devtools_app/lib/src/service/service_registrations.dart
index 188cd54..52c049f 100644
--- a/packages/devtools_app/lib/src/service/service_registrations.dart
+++ b/packages/devtools_app/lib/src/service/service_registrations.dart
@@ -64,8 +64,9 @@
 /// Service extension to enable or disable semantics on the connected app.
 const enableSemantics = 'ext.flutter.accessibility.enableSemantics';
 
-/// Service extension to fetch the accessibility semantics tree from the connected app.
-const getSemanticsTree = 'ext.flutter.accessibility.getSemanticsTree';
+/// Service extension to fetch the accessibility semantics tree and any accessibility issues.
+const getIssuesAndSemanticsTree =
+    'ext.flutter.accessibility.getIssuesAndSemanticsTree';
 
 /// Service extension to dispose semantics on the connected app.
 const disposeSemantics = 'ext.flutter.accessibility.disposeSemantics';
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
index a39b9f5..2575cb0 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
@@ -35,13 +35,13 @@
               .serviceExtensionResponses[registrations.enableSemantics] =
           Response.parse({})!;
       fakeServiceConnection
-              .serviceManager
-              .serviceExtensionResponses[registrations.getSemanticsTree] =
-          Response.parse({
-            'data': {
-              '0': {'id': '0', 'label': 'Root'},
-            },
-          })!;
+          .serviceManager
+          .serviceExtensionResponses[registrations
+          .getIssuesAndSemanticsTree] = Response.parse({
+        'data': {
+          '0': {'id': '0', 'label': 'Root'},
+        },
+      })!;
 
       setGlobal(NotificationService, NotificationService());
       setGlobal(
@@ -148,6 +148,12 @@
       'SemanticsNodeModel properties, parseRect, rectDisplay, and shallowCopy',
       () {
         const expectedRect = Rect.fromLTWH(0, 0, 763, 32);
+        const expectedIssues = [
+          AccessibilityIssue(
+            rule: 'labeledTapTarget',
+            description: 'Interactive element has no label for screen readers.',
+          ),
+        ];
         final child = SemanticsNodeModel(
           id: '1',
           label: 'Child Node',
@@ -155,6 +161,7 @@
           hint: 'Child Hint',
           rect: expectedRect,
           flags: {SemanticsFlag.isButton, SemanticsFlag.hasCheckedState},
+          issues: expectedIssues,
           widgetName: 'ElevatedButton',
         );
         final parent = SemanticsNodeModel(
@@ -179,6 +186,7 @@
           copy.flags,
           equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
         );
+        expect(copy.issues, equals(expectedIssues));
         expect(copy.widgetName, equals('ElevatedButton'));
         expect(copy.children, isEmpty);
 
@@ -234,7 +242,7 @@
           {},
         )!;
         fakeServiceManager.serviceExtensionResponses[registrations
-            .getSemanticsTree] = Response.parse({
+            .getIssuesAndSemanticsTree] = Response.parse({
           'error': 'Semantics not enabled.',
         })!;
 
@@ -262,7 +270,7 @@
           {},
         )!;
         fakeServiceManager.serviceExtensionResponses[registrations
-            .getSemanticsTree] = Response.parse({
+            .getIssuesAndSemanticsTree] = Response.parse({
           'data': {
             '0': {
               'id': 0,
@@ -419,7 +427,7 @@
           {},
         )!;
         fakeServiceManager.serviceExtensionResponses[registrations
-            .getSemanticsTree] = Response.parse({
+            .getIssuesAndSemanticsTree] = Response.parse({
           'data': {
             '0': {
               'id': 0,
@@ -448,6 +456,58 @@
       },
     );
 
+    test(
+      'loadSemanticsTree parses accessibility issues from getIssuesAndSemanticsTree',
+      () async {
+        final fakeServiceManager =
+            serviceConnection.serviceManager as FakeServiceManager;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .enableSemantics] = Response.parse(
+          {},
+        )!;
+        fakeServiceManager.serviceExtensionResponses[registrations
+            .getIssuesAndSemanticsTree] = Response.parse({
+          'data': {
+            '0': {
+              'id': 0,
+              'label': 'Root',
+              'childrenInTraversalOrder': [13],
+            },
+            '13': {
+              'id': 13,
+              'label': '',
+              'flags': ['isButton'],
+            },
+          },
+          'issues': [
+            {
+              'nodeId': 13,
+              'rule': 'labeledTapTarget',
+              'description':
+                  'Interactive element has no label for screen readers.',
+            },
+          ],
+        })!;
+
+        final testController = AccessibilityController();
+        await testController.loadSemanticsTree();
+
+        expect(testController.semanticsRoots.value, hasLength(1));
+        final root = testController.semanticsRoots.value.first;
+        expect(root.issues, isEmpty);
+        expect(root.children, hasLength(1));
+
+        final child = root.children.first;
+        expect(child.id, equals('13'));
+        expect(child.issues, hasLength(1));
+        expect(child.issues.first.rule, equals('labeledTapTarget'));
+        expect(
+          child.issues.first.description,
+          equals('Interactive element has no label for screen readers.'),
+        );
+      },
+    );
+
     test('dispose calls disposeSemantics', () async {
       final recordingServiceConnection = _RecordingServiceConnectionManager();
       setGlobal(ServiceConnectionManager, recordingServiceConnection);
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
index e13b494..e1c14e9 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
@@ -511,5 +511,85 @@
         expect(controller.selectedSemanticsNode.value, equals(childNode));
       },
     );
+
+    testWidgetsWithWindowSize(
+      'renders accessibility issues in tree pane and node details pane',
+      windowSize,
+      (WidgetTester tester) async {
+        final nodeWithIssue = SemanticsNodeModel(
+          id: '13',
+          issues: const [
+            AccessibilityIssue(
+              rule: 'labeledTapTarget',
+              description:
+                  'Interactive element has no label for screen readers.',
+            ),
+          ],
+        );
+        final rootNode = SemanticsNodeModel(id: '0', label: 'Root Node')
+          ..addChild(nodeWithIssue);
+
+        rootNode.expandCascading();
+        controller.semanticsRoots.value = [rootNode];
+
+        await pumpAccessibilityScreen(tester);
+        await tester.pumpAndSettle();
+
+        final treePane = find.byType(AccessibilitySemanticsTreePane);
+        final detailsPane = find.byType(SemanticsNodeDetailsPane);
+
+        // Tree pane should display a warning icon for node #13.
+        expect(
+          find.descendant(
+            of: treePane,
+            matching: find.byIcon(Icons.warning_amber_rounded),
+          ),
+          findsOneWidget,
+        );
+
+        // Initially root node (#0) has no issues, so details pane has no issues section.
+        expect(
+          find.descendant(
+            of: detailsPane,
+            matching: find.textContaining('Accessibility Issues'),
+          ),
+          findsNothing,
+        );
+
+        // Select node #13 in the tree pane.
+        await tester.tap(
+          find.descendant(
+            of: treePane,
+            matching: find.text('SemanticsNode #13'),
+          ),
+        );
+        await tester.pumpAndSettle();
+
+        // Details pane should now show the Accessibility Issues section and card.
+        expect(
+          find.descendant(
+            of: detailsPane,
+            matching: find.text('Accessibility Issues (1)'),
+          ),
+          findsOneWidget,
+        );
+        expect(
+          find.descendant(
+            of: detailsPane,
+            matching: find.text('labeledTapTarget'),
+          ),
+          findsOneWidget,
+        );
+        expect(
+          find.descendant(
+            of: detailsPane,
+            matching: find.text(
+              'Interactive element has no label for screen readers.',
+            ),
+          ),
+          findsOneWidget,
+        );
+      },
+    );
   });
 }