"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._();