Add debugPaintFocusBoxes (#188288)

Adds a new debug global, `debugPaintFocusBoxes`, to visualize the focus
tree:

* Green box: the primary focus node
* Blue boxes: focus nodes that are ancestors of the primary focus node
* Cyan boxes: focus nodes that are focusable and traversable
* Yellow boxes: focus nodes that are focusable but not traversable
* Red boxes: focus nodes that are not focusable

## Demo

```dart
void main() {
  debugPaintFocusBoxes = true;
  runApp(const CalendarDatePickerApp());
}
```


https://github.com/user-attachments/assets/208ba4fc-d806-4ce8-93bc-4161c0f27328

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
diff --git a/packages/flutter/lib/src/widgets/debug.dart b/packages/flutter/lib/src/widgets/debug.dart
index 3f9a117..9e252ed 100644
--- a/packages/flutter/lib/src/widgets/debug.dart
+++ b/packages/flutter/lib/src/widgets/debug.dart
@@ -7,6 +7,8 @@
 /// @docImport 'package:flutter/scheduler.dart';
 ///
 /// @docImport 'binding.dart';
+/// @docImport 'focus_manager.dart';
+/// @docImport 'focus_scope.dart';
 /// @docImport 'widget_inspector.dart';
 library;
 
@@ -175,6 +177,29 @@
 /// Show banners for deprecated widgets.
 bool debugHighlightDeprecatedWidgets = false;
 
+/// Causes each [Focus] widget to paint a box around its bounds.
+///
+/// Different colors indicate different focus states:
+///
+///  * Green: the node has primary focus.
+///  * Blue: the node is in the focus chain but does not
+///    have primary focus (i.e., it is an ancestor of the primary focus).
+///  * Cyan: the node is focusable and participates in focus traversal.
+///  * Yellow: the node skips focus traversal ([FocusNode.skipTraversal] is true)
+///    but can still receive focus directly.
+///  * Red: the node cannot receive focus ([FocusNode.canRequestFocus] is false).
+///
+/// Enabling this causes each [Focus] widget to wrap its child with a widget,
+/// which can cause state loss if the child is a stateful widget that isn't keyed.
+///
+/// This has no effect in release builds.
+///
+/// See also:
+///
+///  * [FocusNode], which manages focus for a widget subtree.
+///  * [debugFocusChanges], which logs to the console when focus changes occur.
+bool debugPaintFocusBoxes = false;
+
 Key? _firstNonUniqueKey(Iterable<Widget> widgets) {
   final Set<Key> keySet = HashSet<Key>();
   for (final widget in widgets) {
@@ -543,7 +568,8 @@
         debugPrintGlobalKeyedWidgetLifecycle ||
         debugProfileBuildsEnabled ||
         debugHighlightDeprecatedWidgets ||
-        debugProfileBuildsEnabledUserWidgets) {
+        debugProfileBuildsEnabledUserWidgets ||
+        debugPaintFocusBoxes) {
       throw FlutterError(reason);
     }
     return true;
diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart
index 4aeb809..e75dda7 100644
--- a/packages/flutter/lib/src/widgets/focus_manager.dart
+++ b/packages/flutter/lib/src/widgets/focus_manager.dart
@@ -4,6 +4,8 @@
 
 /// @docImport 'package:flutter/material.dart';
 /// @docImport 'package:flutter/rendering.dart';
+///
+/// @docImport 'debug.dart';
 library;
 
 import 'dart:async';
@@ -26,6 +28,12 @@
 /// Can be used to debug focus issues: each time the focus changes, the focus
 /// tree will be printed and requests for focus and other focus operations will
 /// be logged.
+///
+/// This has no effect in release builds.
+///
+/// See also:
+///
+/// * [debugPaintFocusBoxes], which draws boxes around focus nodes.
 bool debugFocusChanges = false;
 
 // When using _focusDebug, always call it like so:
diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart
index 661b651..98445d0 100644
--- a/packages/flutter/lib/src/widgets/focus_scope.dart
+++ b/packages/flutter/lib/src/widgets/focus_scope.dart
@@ -16,9 +16,12 @@
 import 'package:flutter/foundation.dart';
 
 import 'basic.dart';
+import 'container.dart';
+import 'debug.dart';
 import 'focus_manager.dart';
 import 'framework.dart';
 import 'inherited_notifier.dart';
+import 'transitions.dart';
 
 /// A widget that manages a [FocusNode] to allow keyboard focus to be given
 /// to this widget and its descendants.
@@ -728,6 +731,12 @@
         child: widget.child,
       );
     }
+    assert(() {
+      if (debugPaintFocusBoxes) {
+        child = _DebugFocusBorder(node: focusNode, child: child);
+      }
+      return true;
+    }());
     return _FocusInheritedScope(node: focusNode, child: child);
   }
 }
@@ -896,6 +905,43 @@
   }
 }
 
+/// Wraps a child with a colored border indicating the focus state of the node.
+/// Only used when [debugPaintFocusBoxes] is true.
+class _DebugFocusBorder extends StatelessWidget {
+  const _DebugFocusBorder({required this.node, required this.child});
+
+  final FocusNode node;
+  final Widget child;
+
+  Color get _borderColor {
+    if (node.hasPrimaryFocus) {
+      return const Color(0xF000FF00);
+    } else if (node.hasFocus) {
+      return const Color(0xF00000FF);
+    } else if (!node.canRequestFocus) {
+      return const Color(0xF0FF0000);
+    } else if (node.skipTraversal) {
+      return const Color(0xF0FFFF00);
+    } else {
+      return const Color(0xF000FFFF);
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return ListenableBuilder(
+      listenable: node,
+      builder: (BuildContext context, _) {
+        return DecoratedBox(
+          decoration: BoxDecoration(border: Border.all(color: _borderColor, width: 3.0)),
+          position: DecorationPosition.foreground,
+          child: child,
+        );
+      },
+    );
+  }
+}
+
 // The InheritedWidget for Focus and FocusScope.
 class _FocusInheritedScope extends InheritedNotifier<FocusNode> {
   const _FocusInheritedScope({required FocusNode node, required super.child})
diff --git a/packages/flutter/test/widgets/debug_test.dart b/packages/flutter/test/widgets/debug_test.dart
index d2e3862..a5c5bf2 100644
--- a/packages/flutter/test/widgets/debug_test.dart
+++ b/packages/flutter/test/widgets/debug_test.dart
@@ -327,4 +327,166 @@
     renderObject = tester.firstRenderObject(find.byType(CompositedTransformFollower));
     expect(renderObject.debugLayer?.debugCreator, isNotNull);
   });
+
+  group('debugPaintFocusBoxes', () {
+    const kPrimaryFocusColor = Color(0xF000FF00);
+    const kAncestorOfPrimaryFocusColor = Color(0xF00000FF);
+    const kFocusableColor =  Color(0xF000FFFF);
+    const kSkipTraversalColor =  Color(0xF0FFFF00);
+    const kNotFocusableColor = Color(0xF0FF0000);
+
+    testWidgets('adds a border on each Focus widget if enabled',
+        (WidgetTester tester) async {
+      debugPaintFocusBoxes = true;
+
+      final nodePrimary = FocusNode(debugLabel: 'primary');
+      final nodeParent = FocusNode(debugLabel: 'parent');
+      final nodeFocusable = FocusNode(debugLabel: 'focusable');
+      final nodeSkipTraversal = FocusNode(debugLabel: 'skipTraversal', skipTraversal: true);
+      final nodeNotFocusable = FocusNode(debugLabel: 'notFocusable', canRequestFocus: false);
+
+      addTearDown(nodePrimary.dispose);
+      addTearDown(nodeParent.dispose);
+      addTearDown(nodeFocusable.dispose);
+      addTearDown(nodeSkipTraversal.dispose);
+      addTearDown(nodeNotFocusable.dispose);
+
+      await tester.pumpWidget(
+        Directionality(
+          textDirection: TextDirection.ltr,
+          child: Column(
+            children: <Widget>[
+              Focus(
+                focusNode: nodeParent,
+                child: Focus(
+                  focusNode: nodePrimary,
+                  child: const SizedBox(width: 10, height: 10),
+                ),
+              ),
+              Focus(focusNode: nodeFocusable, child: const SizedBox(width: 10, height: 10)),
+              Focus(focusNode: nodeSkipTraversal, child: const SizedBox(width: 10, height: 10)),
+              Focus(focusNode: nodeNotFocusable, child: const SizedBox(width: 10, height: 10)),
+            ],
+          ),
+        ),
+      );
+
+      nodePrimary.requestFocus();
+      await tester.pumpAndSettle();
+
+      Color borderColorOf(FocusNode node) {
+        final Finder finder = find.descendant(
+          of: find.byWidgetPredicate((w) => w is Focus && w.focusNode == node),
+          matching: find.byType(DecoratedBox),
+        );
+        final DecoratedBox box = tester.widget<DecoratedBox>(finder.first);
+        return ((box.decoration as BoxDecoration).border! as Border).top.color;
+      }
+
+      expect(borderColorOf(nodePrimary), kPrimaryFocusColor);
+      expect(borderColorOf(nodeParent), kAncestorOfPrimaryFocusColor);
+      expect(borderColorOf(nodeFocusable), kFocusableColor);
+      expect(borderColorOf(nodeSkipTraversal), kSkipTraversalColor);
+      expect(borderColorOf(nodeNotFocusable), kNotFocusableColor);
+
+      debugPaintFocusBoxes = false;
+    });
+
+    testWidgets('does not add a border if disabled',
+        (WidgetTester tester) async {
+      final focusNode = FocusNode();
+      addTearDown(focusNode.dispose);
+
+      await tester.pumpWidget(
+        Directionality(
+          textDirection: TextDirection.ltr,
+          child: Focus(
+            focusNode: focusNode,
+            child: const SizedBox(width: 100, height: 100),
+          ),
+        ),
+      );
+
+      expect(find.byType(DecoratedBox), findsNothing);
+    });
+
+    testWidgets('border updates when focus changes', (WidgetTester tester) async {
+      debugPaintFocusBoxes = true;
+      final focusNode = FocusNode();
+      addTearDown(focusNode.dispose);
+
+      await tester.pumpWidget(
+        Directionality(
+          textDirection: TextDirection.ltr,
+          child: Focus(
+            focusNode: focusNode,
+            child: const SizedBox(width: 100, height: 100),
+          ),
+        ),
+      );
+
+      Color borderColor() {
+        final Finder finder = find.descendant(
+          of: find.byWidgetPredicate((w) => w is Focus && w.focusNode == focusNode),
+          matching: find.byType(DecoratedBox),
+        );
+        final DecoratedBox box = tester.widget<DecoratedBox>(finder.first);
+        return ((box.decoration as BoxDecoration).border! as Border).top.color;
+      }
+
+      // Start unfocused: cyan.
+      expect(borderColor(), kFocusableColor);
+
+      // Gain primary focus: green.
+      focusNode.requestFocus();
+      await tester.pumpAndSettle();
+      expect(borderColor(), kPrimaryFocusColor);
+
+      // Skip traversal but primary focus: green.
+      focusNode.skipTraversal = true;
+      await tester.pumpAndSettle();
+      expect(borderColor(), kPrimaryFocusColor);
+
+      // Lose primary focus and skip traversal: yellow.
+      focusNode.unfocus();
+      await tester.pumpAndSettle();
+      expect(borderColor(), kSkipTraversalColor);
+
+      // Not focusable: red.
+      focusNode.canRequestFocus = false;
+      await tester.pumpAndSettle();
+      expect(borderColor(), kNotFocusableColor);
+
+      debugPaintFocusBoxes = false;
+    });
+
+    testWidgets('no exceptions with multiple focus states', (WidgetTester tester) async {
+      debugPaintFocusBoxes = true;
+      final primary = FocusNode();
+      final unfocusable = FocusNode(canRequestFocus: false);
+      final skipTraversalNode = FocusNode(skipTraversal: true);
+      addTearDown(primary.dispose);
+      addTearDown(unfocusable.dispose);
+      addTearDown(skipTraversalNode.dispose);
+
+      await tester.pumpWidget(
+        Directionality(
+          textDirection: TextDirection.ltr,
+          child: Column(
+            children: <Widget>[
+              Focus(focusNode: primary, child: const SizedBox(width: 10, height: 10)),
+              Focus(focusNode: unfocusable, child: const SizedBox(width: 10, height: 10)),
+              Focus(focusNode: skipTraversalNode, child: const SizedBox(width: 10, height: 10)),
+            ],
+          ),
+        ),
+      );
+
+      primary.requestFocus();
+      await tester.pump();
+      expect(tester.takeException(), isNull);
+
+      debugPaintFocusBoxes = false;
+    });
+  });
 }