"Fossies" - the Fresh Open Source Software Archive

Member "flutter-3.7.0/packages/flutter_test/lib/src/accessibility.dart" (24 Jan 2023, 26993 Bytes) of package /linux/misc/flutter-3.7.0.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Dart source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file. See also the latest Fossies "Diffs" side-by-side code changes report for "accessibility.dart": 3.3.10_vs_3.7.0.

    1 // Copyright 2014 The Flutter Authors. All rights reserved.
    2 // Use of this source code is governed by a BSD-style license that can be
    3 // found in the LICENSE file.
    4 
    5 import 'dart:async';
    6 import 'dart:ui' as ui;
    7 
    8 import 'package:flutter/foundation.dart';
    9 import 'package:flutter/rendering.dart';
   10 import 'package:flutter/widgets.dart';
   11 
   12 import 'finders.dart';
   13 import 'widget_tester.dart';
   14 
   15 /// The result of evaluating a semantics node by a [AccessibilityGuideline].
   16 class Evaluation {
   17   /// Create a passing evaluation.
   18   const Evaluation.pass()
   19       : passed = true,
   20         reason = null;
   21 
   22   /// Create a failing evaluation, with an optional [reason] explaining the
   23   /// result.
   24   const Evaluation.fail([this.reason]) : passed = false;
   25 
   26   // private constructor for adding cases together.
   27   const Evaluation._(this.passed, this.reason);
   28 
   29   /// Whether the given tree or node passed the policy evaluation.
   30   final bool passed;
   31 
   32   /// If [passed] is false, contains the reason for failure.
   33   final String? reason;
   34 
   35   /// Combines two evaluation results.
   36   ///
   37   /// The [reason] will be concatenated with a newline, and [passed] will be
   38   /// combined with an `&&` operator.
   39   Evaluation operator +(Evaluation? other) {
   40     if (other == null) {
   41       return this;
   42     }
   43 
   44     final StringBuffer buffer = StringBuffer();
   45     if (reason != null) {
   46       buffer.write(reason);
   47       buffer.write(' ');
   48     }
   49     if (other.reason != null) {
   50       buffer.write(other.reason);
   51     }
   52     return Evaluation._(
   53       passed && other.passed,
   54       buffer.isEmpty ? null : buffer.toString(),
   55     );
   56   }
   57 }
   58 
   59 /// An accessibility guideline describes a recommendation an application should
   60 /// meet to be considered accessible.
   61 ///
   62 /// Use [meetsGuideline] matcher to test whether a screen meets the
   63 /// accessibility guideline.
   64 ///
   65 /// {@tool snippet}
   66 ///
   67 /// This sample demonstrates how to run an accessibility guideline in a unit
   68 /// test against a single screen.
   69 ///
   70 /// ```dart
   71 /// testWidgets('HomePage meets androidTapTargetGuideline', (WidgetTester tester) async {
   72 ///   final SemanticsHandle handle = tester.ensureSemantics();
   73 ///   await tester.pumpWidget(const MaterialApp(home: HomePage()));
   74 ///   await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
   75 ///   handle.dispose();
   76 /// });
   77 /// ```
   78 /// {@end-tool}
   79 ///
   80 /// See also:
   81 ///  * [androidTapTargetGuideline], which checks that tappable nodes have a
   82 ///    minimum size of 48 by 48 pixels.
   83 ///  * [iOSTapTargetGuideline], which checks that tappable nodes have a minimum
   84 ///    size of 44 by 44 pixels.
   85 ///  * [textContrastGuideline], which provides guidance for text contrast
   86 ///    requirements specified by [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef).
   87 ///  * [labeledTapTargetGuideline], which enforces that all nodes with a tap or
   88 ///    long press action also have a label.
   89 abstract class AccessibilityGuideline {
   90   /// A const constructor allows subclasses to be const.
   91   const AccessibilityGuideline();
   92 
   93   /// Evaluate whether the current state of the `tester` conforms to the rule.
   94   FutureOr<Evaluation> evaluate(WidgetTester tester);
   95 
   96   /// A description of the policy restrictions and criteria.
   97   String get description;
   98 }
   99 
  100 /// A guideline which enforces that all tappable semantics nodes have a minimum
  101 /// size.
  102 ///
  103 /// Each platform defines its own guidelines for minimum tap areas.
  104 ///
  105 /// See also:
  106 ///  * [AccessibilityGuideline], which provides a general overview of
  107 ///    accessibility guidelines and how to use them.
  108 ///  * [androidTapTargetGuideline], which checks that tappable nodes have a
  109 ///    minimum size of 48 by 48 pixels.
  110 ///  * [iOSTapTargetGuideline], which checks that tappable nodes have a minimum
  111 ///    size of 44 by 44 pixels.
  112 @visibleForTesting
  113 class MinimumTapTargetGuideline extends AccessibilityGuideline {
  114   /// Create a new [MinimumTapTargetGuideline].
  115   const MinimumTapTargetGuideline({required this.size, required this.link});
  116 
  117   /// The minimum allowed size of a tappable node.
  118   final Size size;
  119 
  120   /// A link describing the tap target guidelines for a platform.
  121   final String link;
  122 
  123   @override
  124   FutureOr<Evaluation> evaluate(WidgetTester tester) {
  125     return _traverse(
  126       tester,
  127       tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!,
  128     );
  129   }
  130 
  131   Evaluation _traverse(WidgetTester tester, SemanticsNode node) {
  132     Evaluation result = const Evaluation.pass();
  133     node.visitChildren((SemanticsNode child) {
  134       result += _traverse(tester, child);
  135       return true;
  136     });
  137     if (node.isMergedIntoParent) {
  138       return result;
  139     }
  140     if (shouldSkipNode(node)) {
  141       return result;
  142     }
  143     Rect paintBounds = node.rect;
  144     SemanticsNode? current = node;
  145     while (current != null) {
  146       final Matrix4? transform = current.transform;
  147       if (transform != null) {
  148         paintBounds = MatrixUtils.transformRect(transform, paintBounds);
  149       }
  150       current = current.parent;
  151     }
  152     // skip node if it is touching the edge of the screen, since it might
  153     // be partially scrolled offscreen.
  154     const double delta = 0.001;
  155     final Size physicalSize = tester.binding.window.physicalSize;
  156     if (paintBounds.left <= delta ||
  157         paintBounds.top <= delta ||
  158         (paintBounds.bottom - physicalSize.height).abs() <= delta ||
  159         (paintBounds.right - physicalSize.width).abs() <= delta) {
  160       return result;
  161     }
  162     // shrink by device pixel ratio.
  163     final Size candidateSize = paintBounds.size / tester.binding.window.devicePixelRatio;
  164     if (candidateSize.width < size.width - delta ||
  165         candidateSize.height < size.height - delta) {
  166       result += Evaluation.fail(
  167         '$node: expected tap target size of at least $size, '
  168         'but found $candidateSize\n'
  169         'See also: $link',
  170       );
  171     }
  172     return result;
  173   }
  174 
  175   /// Returns whether [SemanticsNode] should be skipped for minimum tap target
  176   /// guideline.
  177   ///
  178   /// Skips nodes which are link, hidden, or do not have actions.
  179   bool shouldSkipNode(SemanticsNode node) {
  180     final SemanticsData data = node.getSemanticsData();
  181     // Skip node if it has no actions, or is marked as hidden.
  182     if ((!data.hasAction(ui.SemanticsAction.longPress) &&
  183             !data.hasAction(ui.SemanticsAction.tap)) ||
  184         data.hasFlag(ui.SemanticsFlag.isHidden)) {
  185       return true;
  186     }
  187     // Skip links https://www.w3.org/WAI/WCAG21/Understanding/target-size.html
  188     if (data.hasFlag(ui.SemanticsFlag.isLink)) {
  189       return true;
  190     }
  191     return false;
  192   }
  193 
  194   @override
  195   String get description => 'Tappable objects should be at least $size';
  196 }
  197 
  198 /// A guideline which enforces that all nodes with a tap or long press action
  199 /// also have a label.
  200 ///
  201 /// See also:
  202 ///  * [AccessibilityGuideline], which provides a general overview of
  203 ///    accessibility guidelines and how to use them.
  204 @visibleForTesting
  205 class LabeledTapTargetGuideline extends AccessibilityGuideline {
  206   const LabeledTapTargetGuideline._();
  207 
  208   @override
  209   String get description => 'Tappable widgets should have a semantic label';
  210 
  211   @override
  212   FutureOr<Evaluation> evaluate(WidgetTester tester) {
  213     final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
  214     Evaluation traverse(SemanticsNode node) {
  215       Evaluation result = const Evaluation.pass();
  216       node.visitChildren((SemanticsNode child) {
  217         result += traverse(child);
  218         return true;
  219       });
  220       if (node.isMergedIntoParent ||
  221           node.isInvisible ||
  222           node.hasFlag(ui.SemanticsFlag.isHidden) ||
  223           node.hasFlag(ui.SemanticsFlag.isTextField)) {
  224         return result;
  225       }
  226       final SemanticsData data = node.getSemanticsData();
  227       // Skip node if it has no actions, or is marked as hidden.
  228       if (!data.hasAction(ui.SemanticsAction.longPress) &&
  229           !data.hasAction(ui.SemanticsAction.tap)) {
  230         return result;
  231       }
  232       if ((data.label == null || data.label.isEmpty) && (data.tooltip == null || data.tooltip.isEmpty)) {
  233         result += Evaluation.fail(
  234           '$node: expected tappable node to have semantic label, '
  235           'but none was found.\n',
  236         );
  237       }
  238       return result;
  239     }
  240 
  241     return traverse(root);
  242   }
  243 }
  244 
  245 /// A guideline which verifies that all nodes that contribute semantics via text
  246 /// meet minimum contrast levels.
  247 ///
  248 /// The guidelines are defined by the Web Content Accessibility Guidelines,
  249 /// http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
  250 ///
  251 /// See also:
  252 ///  * [AccessibilityGuideline], which provides a general overview of
  253 ///    accessibility guidelines and how to use them.
  254 @visibleForTesting
  255 class MinimumTextContrastGuideline extends AccessibilityGuideline {
  256   /// Create a new [MinimumTextContrastGuideline].
  257   const MinimumTextContrastGuideline();
  258 
  259   /// The minimum text size considered large for contrast checking.
  260   ///
  261   /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
  262   static const int kLargeTextMinimumSize = 18;
  263 
  264   /// The minimum text size for bold text to be considered large for contrast
  265   /// checking.
  266   ///
  267   /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
  268   static const int kBoldTextMinimumSize = 14;
  269 
  270   /// The minimum contrast ratio for normal text.
  271   ///
  272   /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
  273   static const double kMinimumRatioNormalText = 4.5;
  274 
  275   /// The minimum contrast ratio for large text.
  276   ///
  277   /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
  278   static const double kMinimumRatioLargeText = 3.0;
  279 
  280   static const double _kDefaultFontSize = 12.0;
  281 
  282   static const double _tolerance = -0.01;
  283 
  284   @override
  285   Future<Evaluation> evaluate(WidgetTester tester) async {
  286     final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
  287     final RenderView renderView = tester.binding.renderView;
  288     final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
  289 
  290     late ui.Image image;
  291     final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
  292       () async {
  293         // Needs to be the same pixel ratio otherwise our dimensions won't match
  294         // the last transform layer.
  295         final double ratio = 1 / tester.binding.window.devicePixelRatio;
  296         image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
  297         return image.toByteData();
  298       },
  299     );
  300 
  301     return _evaluateNode(root, tester, image, byteData!);
  302   }
  303 
  304   Future<Evaluation> _evaluateNode(
  305     SemanticsNode node,
  306     WidgetTester tester,
  307     ui.Image image,
  308     ByteData byteData,
  309   ) async {
  310     Evaluation result = const Evaluation.pass();
  311 
  312     // Skip disabled nodes, as they not required to pass contrast check.
  313     final bool isDisabled = node.hasFlag(ui.SemanticsFlag.hasEnabledState) &&
  314         !node.hasFlag(ui.SemanticsFlag.isEnabled);
  315 
  316     if (node.isInvisible ||
  317         node.isMergedIntoParent ||
  318         node.hasFlag(ui.SemanticsFlag.isHidden) ||
  319         isDisabled) {
  320       return result;
  321     }
  322 
  323     final SemanticsData data = node.getSemanticsData();
  324     final List<SemanticsNode> children = <SemanticsNode>[];
  325     node.visitChildren((SemanticsNode child) {
  326       children.add(child);
  327       return true;
  328     });
  329     for (final SemanticsNode child in children) {
  330       result += await _evaluateNode(child, tester, image, byteData);
  331     }
  332     if (shouldSkipNode(data)) {
  333       return result;
  334     }
  335     final String text = data.label.isEmpty ? data.value : data.label;
  336     final Iterable<Element> elements = find.text(text).hitTestable().evaluate();
  337     for (final Element element in elements) {
  338       result += await _evaluateElement(node, element, tester, image, byteData);
  339     }
  340     return result;
  341   }
  342 
  343   Future<Evaluation> _evaluateElement(
  344     SemanticsNode node,
  345     Element element,
  346     WidgetTester tester,
  347     ui.Image image,
  348     ByteData byteData,
  349   ) async {
  350     // Look up inherited text properties to determine text size and weight.
  351     late bool isBold;
  352     double? fontSize;
  353 
  354     late final Rect screenBounds;
  355     late final Rect paintBoundsWithOffset;
  356 
  357     final RenderObject? renderBox = element.renderObject;
  358     if (renderBox is! RenderBox) {
  359       throw StateError('Unexpected renderObject type: $renderBox');
  360     }
  361 
  362     final Matrix4 globalTransform = renderBox.getTransformTo(null);
  363     paintBoundsWithOffset = MatrixUtils.transformRect(globalTransform, renderBox.paintBounds.inflate(4.0));
  364 
  365     // The semantics node transform will include root view transform, which is
  366     // not included in renderBox.getTransformTo(null). Manually multiply the
  367     // root transform to the global transform.
  368     final Matrix4 rootTransform = Matrix4.identity();
  369     tester.binding.renderView.applyPaintTransform(tester.binding.renderView.child!, rootTransform);
  370     rootTransform.multiply(globalTransform);
  371     screenBounds = MatrixUtils.transformRect(rootTransform, renderBox.paintBounds);
  372     Rect nodeBounds = node.rect;
  373     SemanticsNode? current = node;
  374     while (current != null) {
  375       final Matrix4? transform = current.transform;
  376       if (transform != null) {
  377         nodeBounds = MatrixUtils.transformRect(transform, nodeBounds);
  378       }
  379       current = current.parent;
  380     }
  381     final Rect intersection = nodeBounds.intersect(screenBounds);
  382     if (intersection.width <= 0 || intersection.height <= 0) {
  383       // Skip this element since it doesn't correspond to the given semantic
  384       // node.
  385       return const Evaluation.pass();
  386     }
  387 
  388     final Widget widget = element.widget;
  389     final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element);
  390     if (widget is Text) {
  391       final TextStyle? style = widget.style;
  392       final TextStyle effectiveTextStyle = style == null || style.inherit
  393           ? defaultTextStyle.style.merge(widget.style)
  394           : style;
  395       isBold = effectiveTextStyle.fontWeight == FontWeight.bold;
  396       fontSize = effectiveTextStyle.fontSize;
  397     } else if (widget is EditableText) {
  398       isBold = widget.style.fontWeight == FontWeight.bold;
  399       fontSize = widget.style.fontSize;
  400     } else {
  401       throw StateError('Unexpected widget type: ${widget.runtimeType}');
  402     }
  403 
  404     if (isNodeOffScreen(paintBoundsWithOffset, tester.binding.window)) {
  405       return const Evaluation.pass();
  406     }
  407 
  408     final Map<Color, int> colorHistogram = _colorsWithinRect(byteData, paintBoundsWithOffset, image.width, image.height);
  409 
  410     // Node was too far off screen.
  411     if (colorHistogram.isEmpty) {
  412       return const Evaluation.pass();
  413     }
  414 
  415     final _ContrastReport report = _ContrastReport(colorHistogram);
  416 
  417     final double contrastRatio = report.contrastRatio();
  418     final double targetContrastRatio = this.targetContrastRatio(fontSize, bold: isBold);
  419 
  420     if (contrastRatio - targetContrastRatio >= _tolerance) {
  421       return const Evaluation.pass();
  422     }
  423     return Evaluation.fail(
  424       '$node:\n'
  425       'Expected contrast ratio of at least $targetContrastRatio '
  426       'but found ${contrastRatio.toStringAsFixed(2)} '
  427       'for a font size of $fontSize.\n'
  428       'The computed colors was:\n'
  429       'light - ${report.lightColor}, dark - ${report.darkColor}\n'
  430       'See also: '
  431       'https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html',
  432     );
  433   }
  434 
  435   /// Returns whether node should be skipped.
  436   ///
  437   /// Skip routes which might have labels, and nodes without any text.
  438   bool shouldSkipNode(SemanticsData data) =>
  439       data.hasFlag(ui.SemanticsFlag.scopesRoute) ||
  440       (data.label.trim().isEmpty && data.value.trim().isEmpty);
  441 
  442   /// Returns if a rectangle of node is off the screen.
  443   ///
  444   /// Allows node to be of screen partially before culling the node.
  445   bool isNodeOffScreen(Rect paintBounds, ui.FlutterView window) {
  446     final Size windowPhysicalSize = window.physicalSize * window.devicePixelRatio;
  447     return paintBounds.top < -50.0 ||
  448            paintBounds.left < -50.0 ||
  449            paintBounds.bottom > windowPhysicalSize.height + 50.0 ||
  450            paintBounds.right > windowPhysicalSize.width + 50.0;
  451   }
  452 
  453   /// Returns the required contrast ratio for the [fontSize] and [bold] setting.
  454   ///
  455   /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
  456   double targetContrastRatio(double? fontSize, {required bool bold}) {
  457     final double fontSizeOrDefault = fontSize ?? _kDefaultFontSize;
  458     if ((bold && fontSizeOrDefault >= kBoldTextMinimumSize) ||
  459         fontSizeOrDefault >= kLargeTextMinimumSize) {
  460       return kMinimumRatioLargeText;
  461     }
  462     return kMinimumRatioNormalText;
  463   }
  464 
  465   @override
  466   String get description => 'Text contrast should follow WCAG guidelines';
  467 }
  468 
  469 /// A guideline which verifies that all elements specified by [finder]
  470 /// meet minimum contrast levels.
  471 ///
  472 /// See also:
  473 ///  * [AccessibilityGuideline], which provides a general overview of
  474 ///    accessibility guidelines and how to use them.
  475 class CustomMinimumContrastGuideline extends AccessibilityGuideline {
  476   /// Creates a custom guideline which verifies that all elements specified
  477   /// by [finder] meet minimum contrast levels.
  478   ///
  479   /// An optional description string can be given using the [description] parameter.
  480   const CustomMinimumContrastGuideline({
  481     required this.finder,
  482     this.minimumRatio = 4.5,
  483     this.tolerance = 0.01,
  484     String description = 'Contrast should follow custom guidelines',
  485   }) : _description = description;
  486 
  487   /// The minimum contrast ratio allowed.
  488   ///
  489   /// Defaults to 4.5, the minimum contrast
  490   /// ratio for normal text, defined by WCAG.
  491   /// See http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
  492   final double minimumRatio;
  493 
  494   /// Tolerance for minimum contrast ratio.
  495   ///
  496   /// Any contrast ratio greater than [minimumRatio] or within a distance of [tolerance]
  497   /// from [minimumRatio] passes the test.
  498   /// Defaults to 0.01.
  499   final double tolerance;
  500 
  501   /// The [Finder] used to find a subset of elements.
  502   ///
  503   /// [finder] determines which subset of elements will be tested for
  504   /// contrast ratio.
  505   final Finder finder;
  506 
  507   final String _description;
  508 
  509   @override
  510   String get description => _description;
  511 
  512   @override
  513   Future<Evaluation> evaluate(WidgetTester tester) async {
  514     // Compute elements to be evaluated.
  515 
  516     final List<Element> elements = finder.evaluate().toList();
  517 
  518     // Obtain rendered image.
  519 
  520     final RenderView renderView = tester.binding.renderView;
  521     final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
  522     late ui.Image image;
  523     final ByteData? byteData = await tester.binding.runAsync<ByteData?>(
  524       () async {
  525         // Needs to be the same pixel ratio otherwise our dimensions won't match
  526         // the last transform layer.
  527         final double ratio = 1 / tester.binding.window.devicePixelRatio;
  528         image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio);
  529         return image.toByteData();
  530       },
  531     );
  532 
  533     // How to evaluate a single element.
  534 
  535     Evaluation evaluateElement(Element element) {
  536       final RenderBox renderObject = element.renderObject! as RenderBox;
  537 
  538       final Rect originalPaintBounds = renderObject.paintBounds;
  539 
  540       final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0);
  541 
  542       final Rect paintBounds = Rect.fromPoints(
  543         renderObject.localToGlobal(inflatedPaintBounds.topLeft),
  544         renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
  545       );
  546 
  547       final Map<Color, int> colorHistogram = _colorsWithinRect(byteData!, paintBounds, image.width, image.height);
  548 
  549       if (colorHistogram.isEmpty) {
  550         return const Evaluation.pass();
  551       }
  552 
  553       final _ContrastReport report = _ContrastReport(colorHistogram);
  554       final double contrastRatio = report.contrastRatio();
  555 
  556       if (contrastRatio >= minimumRatio - tolerance) {
  557         return const Evaluation.pass();
  558       } else {
  559         return Evaluation.fail(
  560           '$element:\nExpected contrast ratio of at least '
  561           '$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
  562           'The computed light color was: ${report.lightColor}, '
  563           'The computed dark color was: ${report.darkColor}\n'
  564           '$description',
  565         );
  566       }
  567     }
  568 
  569     // Collate all evaluations into a final evaluation, then return.
  570 
  571     Evaluation result = const Evaluation.pass();
  572 
  573     for (final Element element in elements) {
  574       result = result + evaluateElement(element);
  575     }
  576 
  577     return result;
  578   }
  579 }
  580 
  581 /// A class that reports the contrast ratio of a part of the screen.
  582 ///
  583 /// Commonly used in accessibility testing to obtain the contrast ratio of
  584 /// text widgets and other types of widgets.
  585 class _ContrastReport {
  586   /// Generates a contrast report given a color histogram.
  587   ///
  588   /// The contrast ratio of the most frequent light color and the most
  589   /// frequent dark color is calculated. Colors are divided into light and
  590   /// dark colors based on their lightness as an [HSLColor].
  591   factory _ContrastReport(Map<Color, int> colorHistogram) {
  592     // To determine the lighter and darker color, partition the colors
  593     // by HSL lightness and then choose the mode from each group.
  594     double totalLightness = 0.0;
  595     int count = 0;
  596     for (final MapEntry<Color, int> entry in colorHistogram.entries) {
  597       totalLightness += HSLColor.fromColor(entry.key).lightness * entry.value;
  598       count += entry.value;
  599     }
  600     final double averageLightness = totalLightness / count;
  601     assert(!averageLightness.isNaN);
  602 
  603     MapEntry<Color, int>? lightColor;
  604     MapEntry<Color, int>? darkColor;
  605 
  606     // Find the most frequently occurring light and dark color.
  607     for (final MapEntry<Color, int> entry in colorHistogram.entries) {
  608       final double lightness = HSLColor.fromColor(entry.key).lightness;
  609       final int count = entry.value;
  610       if (lightness <= averageLightness) {
  611         if (count > (darkColor?.value ?? 0)) {
  612           darkColor = entry;
  613         }
  614       } else if (count > (lightColor?.value ?? 0)) {
  615         lightColor = entry;
  616       }
  617     }
  618 
  619     // If there is only single color, it is reported as both dark and light.
  620     return _ContrastReport._(
  621       lightColor?.key ?? darkColor!.key,
  622       darkColor?.key ?? lightColor!.key,
  623     );
  624   }
  625 
  626   const _ContrastReport._(this.lightColor, this.darkColor);
  627 
  628   /// The most frequently occurring light color. Uses [Colors.transparent] if
  629   /// the rectangle is empty.
  630   final Color lightColor;
  631 
  632   /// The most frequently occurring dark color. Uses [Colors.transparent] if
  633   /// the rectangle is empty.
  634   final Color darkColor;
  635 
  636   /// Computes the contrast ratio as defined by the WCAG.
  637   ///
  638   /// Source: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
  639   double contrastRatio() => (lightColor.computeLuminance() + 0.05) / (darkColor.computeLuminance() + 0.05);
  640 }
  641 
  642 /// Gives the color histogram of all pixels inside a given rectangle on the
  643 /// screen.
  644 ///
  645 /// Given a [ByteData] object [data], which stores the color of each pixel
  646 /// in row-first order, where each pixel is given in 4 bytes in RGBA order,
  647 /// and [paintBounds], the rectangle, and [width] and [height],
  648 //  the dimensions of the [ByteData] returns color histogram.
  649 Map<Color, int> _colorsWithinRect(
  650     ByteData data,
  651     Rect paintBounds,
  652     int width,
  653     int height,
  654 ) {
  655   final Rect truePaintBounds = paintBounds.intersect(Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()));
  656 
  657   final int leftX = truePaintBounds.left.floor();
  658   final int rightX = truePaintBounds.right.ceil();
  659   final int topY = truePaintBounds.top.floor();
  660   final int bottomY = truePaintBounds.bottom.ceil();
  661 
  662   final Map<int, int> rgbaToCount = <int, int>{};
  663 
  664   int getPixel(ByteData data, int x, int y) {
  665     final int offset = (y * width + x) * 4;
  666     return data.getUint32(offset);
  667   }
  668 
  669   for (int x = leftX; x < rightX; x++) {
  670     for (int y = topY; y < bottomY; y++) {
  671       rgbaToCount.update(
  672         getPixel(data, x, y),
  673         (int count) => count + 1,
  674         ifAbsent: () => 1,
  675       );
  676     }
  677   }
  678 
  679   return rgbaToCount.map<Color, int>((int rgba, int count) {
  680     final int argb =  (rgba << 24) | (rgba >> 8) & 0xFFFFFFFF;
  681     return MapEntry<Color, int>(Color(argb), count);
  682   });
  683 }
  684 
  685 /// A guideline which requires tappable semantic nodes a minimum size of
  686 /// 48 by 48.
  687 ///
  688 /// See also:
  689 ///
  690 ///  * [Android tap target guidelines](https://support.google.com/accessibility/android/answer/7101858?hl=en).
  691 ///  * [AccessibilityGuideline], which provides a general overview of
  692 ///    accessibility guidelines and how to use them.
  693 ///  * [iOSTapTargetGuideline], which checks that tappable nodes have a minimum
  694 ///    size of 44 by 44 pixels.
  695 const AccessibilityGuideline androidTapTargetGuideline = MinimumTapTargetGuideline(
  696   size: Size(48.0, 48.0),
  697   link: 'https://support.google.com/accessibility/android/answer/7101858?hl=en',
  698 );
  699 
  700 /// A guideline which requires tappable semantic nodes a minimum size of
  701 /// 44 by 44.
  702 ///
  703 /// See also:
  704 ///
  705 ///  * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/).
  706 ///  * [AccessibilityGuideline], which provides a general overview of
  707 ///    accessibility guidelines and how to use them.
  708 ///  * [androidTapTargetGuideline], which checks that tappable nodes have a
  709 ///    minimum size of 48 by 48 pixels.
  710 const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline(
  711   size: Size(44.0, 44.0),
  712   link: 'https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/',
  713 );
  714 
  715 /// A guideline which requires text contrast to meet minimum values.
  716 ///
  717 /// This guideline traverses the semantics tree looking for nodes with values or
  718 /// labels that corresponds to a Text or Editable text widget. Given the
  719 /// background pixels for the area around this widget, it performs a very naive
  720 /// partitioning of the colors into "light" and "dark" and then chooses the most
  721 /// frequently occurring color in each partition as a representative of the
  722 /// foreground and background colors. The contrast ratio is calculated from
  723 /// these colors according to the [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef)
  724 ///
  725 ///  * [AccessibilityGuideline], which provides a general overview of
  726 ///    accessibility guidelines and how to use them.
  727 const AccessibilityGuideline textContrastGuideline = MinimumTextContrastGuideline();
  728 
  729 /// A guideline which enforces that all nodes with a tap or long press action
  730 /// also have a label.
  731 ///
  732 ///  * [AccessibilityGuideline], which provides a general overview of
  733 ///    accessibility guidelines and how to use them.
  734 const AccessibilityGuideline labeledTapTargetGuideline = LabeledTapTargetGuideline._();