From 1ba3f99bb0aa028a7f092cf4f05959192060b6b1 Mon Sep 17 00:00:00 2001 From: Mitchell Goodwin <58190796+MitchellGoodwin@users.noreply.github.com> Date: Tue, 21 Mar 2023 07:48:34 -0700 Subject: [PATCH] Create cupertino checkbox (#122244) Create cupertino checkbox --- packages/flutter/lib/cupertino.dart | 1 + .../flutter/lib/src/cupertino/checkbox.dart | 396 +++++++++++++++++ .../flutter/lib/src/cupertino/toggleable.dart | 247 +++++++++++ .../flutter/test/cupertino/checkbox_test.dart | 414 ++++++++++++++++++ 4 files changed, 1058 insertions(+) create mode 100644 packages/flutter/lib/src/cupertino/checkbox.dart create mode 100644 packages/flutter/lib/src/cupertino/toggleable.dart create mode 100644 packages/flutter/test/cupertino/checkbox_test.dart diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index af3de265c0..bdbf063a29 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -27,6 +27,7 @@ export 'src/cupertino/adaptive_text_selection_toolbar.dart'; export 'src/cupertino/app.dart'; export 'src/cupertino/bottom_tab_bar.dart'; export 'src/cupertino/button.dart'; +export 'src/cupertino/checkbox.dart'; export 'src/cupertino/colors.dart'; export 'src/cupertino/constants.dart'; export 'src/cupertino/context_menu.dart'; diff --git a/packages/flutter/lib/src/cupertino/checkbox.dart b/packages/flutter/lib/src/cupertino/checkbox.dart new file mode 100644 index 0000000000..9d37c7af9c --- /dev/null +++ b/packages/flutter/lib/src/cupertino/checkbox.dart @@ -0,0 +1,396 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'toggleable.dart'; + +// Examples can assume: +// bool _throwShotAway = false; +// late StateSetter setState; + +// The relative values needed to transform a color to it's equivilant focus +// outline color. +const double _kCupertinoFocusColorOpacity = 0.80; +const double _kCupertinoFocusColorBrightness = 0.69; +const double _kCupertinoFocusColorSaturation = 0.835; + +/// A macOS style checkbox. +/// +/// The checkbox itself does not maintain any state. Instead, when the state of +/// the checkbox changes, the widget calls the [onChanged] callback. Most +/// widgets that use a checkbox will listen for the [onChanged] callback and +/// rebuild the checkbox with a new [value] to update the visual appearance of +/// the checkbox. +/// +/// The checkbox can optionally display three values - true, false, and null - +/// if [tristate] is true. When [value] is null a dash is displayed. By default +/// [tristate] is false and the checkbox's [value] must be true or false. +/// +/// In the Apple Human Interface Guidelines (HIG), checkboxes are encouraged for +/// use on macOS, but is silent about their use on iOS. If a multi-selection +/// component is needed on iOS, the HIG encourages the developer to use switches +/// ([CupertinoSwitch] in Flutter) instead, or to find a creative custom +/// solution. +/// +/// See also: +/// +/// * [Checkbox], the Material Design equivalent. +/// * [CupertinoSwitch], a widget with semantics similar to [CupertinoCheckbox]. +/// * [CupertinoSlider], for selecting a value in a range. +/// * +class CupertinoCheckbox extends StatefulWidget { + /// Creates a macOS-styled checkbox. + /// + /// The checkbox itself does not maintain any state. Instead, when the state of + /// the checkbox changes, the widget calls the [onChanged] callback. Most + /// widgets that use a checkbox will listen for the [onChanged] callback and + /// rebuild the checkbox with a new [value] to update the visual appearance of + /// the checkbox. + /// + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked. The [value] + /// can only be null if [tristate] is true. + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. + /// + /// The values of [tristate] and [autofocus] must not be null. + const CupertinoCheckbox({ + super.key, + required this.value, + this.tristate = false, + required this.onChanged, + this.activeColor, + this.inactiveColor, + this.checkColor, + this.focusColor, + this.focusNode, + this.autofocus = false, + this.side, + this.shape, + }) : assert(tristate || value != null); + + /// Whether this checkbox is checked. + /// + /// When [tristate] is true, a value of null corresponds to the mixed state. + /// When [tristate] is false, this value must not be null. This is asserted in + /// debug mode. + final bool? value; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox with the new + /// value. + /// + /// If this callback is null, the checkbox will be displayed as disabled + /// and will not respond to input gestures. + /// + /// When the checkbox is tapped, if [tristate] is false (the default) then + /// the [onChanged] callback will be applied to `!value`. If [tristate] is + /// true this callback cycle from false to true to null and back to false + /// again. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CupertinoCheckbox( + /// value: _throwShotAway, + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue!; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged? onChanged; + + /// The color to use when this checkbox is checked. + /// + /// Defaults to [CupertinoColors.activeBlue]. + final Color? activeColor; + + /// The color used if the checkbox is inactive. + /// + /// By default, [CupertinoColors.inactiveGray] is used. + final Color? inactiveColor; + + /// The color to use for the check icon when this checkbox is checked. + /// + /// If null, then the value of [CupertinoColors.white] is used. + final Color? checkColor; + + /// If true, the checkbox's [value] can be true, false, or null. + /// + /// [CupertinoCheckbox] displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null, and + /// [onChanged] will only toggle between true and false. + final bool tristate; + + /// The color for the checkbox's border shadow when it has the input focus. + /// + /// If null, then a paler form of the [activeColor] will be used. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The color and width of the checkbox's border. + /// + /// If this property is null, then the side defaults to a one pixel wide + /// black, solid border. + final BorderSide? side; + + /// The shape of the checkbox. + /// + /// If this property is null then the shape defaults to a + /// [RoundedRectangleBorder] with a circular corner radius of 4.0. + final OutlinedBorder? shape; + + /// The width of a checkbox widget. + static const double width = 18.0; + + @override + State createState() => _CupertinoCheckboxState(); +} + +class _CupertinoCheckboxState extends State with TickerProviderStateMixin, ToggleableStateMixin { + final _CheckboxPainter _painter = _CheckboxPainter(); + bool? _previousValue; + + bool focused = false; + + @override + void initState() { + super.initState(); + _previousValue = widget.value; + } + + @override + void didUpdateWidget(CupertinoCheckbox oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _previousValue = oldWidget.value; + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + @override + ValueChanged? get onChanged => widget.onChanged; + + @override + bool get tristate => widget.tristate; + + @override + bool? get value => widget.value; + + void onFocusChange(bool value) { + if (focused != value) { + focused = value; + } + } + + @override + Widget build(BuildContext context) { + final Color effectiveActiveColor = widget.activeColor + ?? CupertinoColors.activeBlue; + final Color? inactiveColor = widget.inactiveColor; + final Color effectiveInactiveColor = inactiveColor + ?? CupertinoColors.inactiveGray; + + final Color effectiveFocusOverlayColor = widget.focusColor + ?? HSLColor + .fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity)) + .withLightness(_kCupertinoFocusColorBrightness) + .withSaturation(_kCupertinoFocusColorSaturation) + .toColor(); + + final Color effectiveCheckColor = widget.checkColor + ?? CupertinoColors.white; + + return Semantics( + checked: widget.value ?? false, + mixed: widget.tristate ? widget.value == null : null, + child: buildToggleable( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: onFocusChange, + size: const Size.square(kMinInteractiveDimensionCupertino), + painter: _painter + ..focusColor = effectiveFocusOverlayColor + ..isFocused = focused + ..downPosition = downPosition + ..activeColor = effectiveActiveColor + ..inactiveColor = effectiveInactiveColor + ..checkColor = effectiveCheckColor + ..value = value + ..previousValue = _previousValue + ..isActive = widget.onChanged != null + ..shape = widget.shape ?? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.0), + ) + ..side = widget.side, + ), + ); + } +} + +class _CheckboxPainter extends ToggleablePainter { + Color get checkColor => _checkColor!; + Color? _checkColor; + set checkColor(Color value) { + if (_checkColor == value) { + return; + } + _checkColor = value; + notifyListeners(); + } + + bool? get value => _value; + bool? _value; + set value(bool? value) { + if (_value == value) { + return; + } + _value = value; + notifyListeners(); + } + + bool? get previousValue => _previousValue; + bool? _previousValue; + set previousValue(bool? value) { + if (_previousValue == value) { + return; + } + _previousValue = value; + notifyListeners(); + } + + OutlinedBorder get shape => _shape!; + OutlinedBorder? _shape; + set shape(OutlinedBorder value) { + if (_shape == value) { + return; + } + _shape = value; + notifyListeners(); + } + + BorderSide? get side => _side; + BorderSide? _side; + set side(BorderSide? value) { + if (_side == value) { + return; + } + _side = value; + notifyListeners(); + } + + Rect _outerRectAt(Offset origin) { + const double size = CupertinoCheckbox.width; + final Rect rect = Rect.fromLTWH(origin.dx, origin.dy, size, size); + return rect; + } + + // The checkbox's border color if value == false, or its fill color when + // value == true or null. + Color _colorAt(bool value) { + return value && isActive ? activeColor : inactiveColor; + } + + // White stroke used to paint the check and dash. + Paint _createStrokePaint() { + return Paint() + ..color = checkColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round; + } + + void _drawBox(Canvas canvas, Rect outer, Paint paint, BorderSide? side, bool fill) { + if (fill) { + canvas.drawPath(shape.getOuterPath(outer), paint); + } + if (side != null) { + shape.copyWith(side: side).paint(canvas, outer); + } + } + + void _drawCheck(Canvas canvas, Offset origin, Paint paint) { + final Path path = Path(); + // The ratios for the offsets below were found from looking at the checkbox + // examples on in the HIG docs. The distance from the needed point to the + // edge was measured, then devided by the total width. + const Offset start = Offset(CupertinoCheckbox.width * 0.25, CupertinoCheckbox.width * 0.52); + const Offset mid = Offset(CupertinoCheckbox.width * 0.46, CupertinoCheckbox.width * 0.75); + const Offset end = Offset(CupertinoCheckbox.width * 0.72, CupertinoCheckbox.width * 0.29); + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy); + canvas.drawPath(path, paint); + path.moveTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.lineTo(origin.dx + end.dx, origin.dy + end.dy); + canvas.drawPath(path, paint); + } + + void _drawDash(Canvas canvas, Offset origin, Paint paint) { + // From measuring the checkbox example in the HIG docs, the dash was found + // to be half the total width, centered in the middle. + const Offset start = Offset(CupertinoCheckbox.width * 0.25, CupertinoCheckbox.width * 0.5); + const Offset end = Offset(CupertinoCheckbox.width * 0.75, CupertinoCheckbox.width * 0.5); + canvas.drawLine(origin + start, origin + end, paint); + } + + @override + void paint(Canvas canvas, Size size) { + final Paint strokePaint = _createStrokePaint(); + final Offset origin = size / 2.0 - const Size.square(CupertinoCheckbox.width) / 2.0 as Offset; + + final Rect outer = _outerRectAt(origin); + final Paint paint = Paint()..color = _colorAt(value ?? true); + + if (value == false) { + + final BorderSide border = side ?? BorderSide(color: paint.color); + _drawBox(canvas, outer, paint, border, false); + } else { + + _drawBox(canvas, outer, paint, side, true); + if (value ?? false) { + _drawCheck(canvas, origin, strokePaint); + } else { + _drawDash(canvas, origin, strokePaint); + } + } + + if (isFocused) { + final Rect focusOuter = outer.inflate(1); + + final Paint borderPaint = Paint() + ..color = focusColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5; + + _drawBox(canvas, focusOuter, borderPaint, side, true); + } + } +} diff --git a/packages/flutter/lib/src/cupertino/toggleable.dart b/packages/flutter/lib/src/cupertino/toggleable.dart new file mode 100644 index 0000000000..78a80ccc01 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/toggleable.dart @@ -0,0 +1,247 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A mixin for [StatefulWidget]s that implements iOS-themed toggleable +/// controls (e.g.[CupertinoCheckbox]es). +/// +/// This mixin implements the logic for toggling the control when tapped. +/// It does not have any opinion about the visual representation of the +/// toggleable widget. The visuals are defined by a [CustomPainter] passed to +/// the [buildToggleable]. [State] objects using this mixin should call that +/// method from their [build] method. +/// +/// This mixin is used to implement the Cupertino components for +/// [CupertinoCheckbox] controls. +@optionalTypeArgs +mixin ToggleableStateMixin on TickerProviderStateMixin { + + /// Whether the [value] of this control can be changed by user interaction. + /// + /// The control is considered interactive if the [onChanged] callback is + /// non-null. If the callback is null, then the control is disabled and + /// non-interactive. A disabled checkbox, for example, is displayed using a + /// grey color and its value cannot be changed. + bool get isInteractive => onChanged != null; + + /// Called when the control changes value. + /// + /// If the control is tapped, [onChanged] is called immediately with the new + /// value. + /// + /// The control is considered interactive (see [isInteractive]) if this + /// callback is non-null. If the callback is null, then the control is + /// disabled and non-interactive. A disabled checkbox, for example, is + /// displayed using a grey color and its value cannot be changed. + ValueChanged? get onChanged; + + /// The [value] accessor returns false if this control is "inactive" (not + /// checked, off, or unselected). + /// + /// If [value] is true then the control "active" (checked, on, or selected). If + /// tristate is true and value is null, then the control is considered to be + /// in its third or "indeterminate" state.. + bool? get value; + + /// If true, [value] can be true, false, or null, otherwise [value] must + /// be true or false. + /// + /// When [tristate] is true and [value] is null, then the control is + /// considered to be in its third or "indeterminate" state. + bool get tristate; + + /// The most recent [Offset] at which a pointer touched the Toggleable. + /// + /// This is null if currently no pointer is touching the Toggleable or if + /// [isInteractive] is false. + Offset? get downPosition => _downPosition; + Offset? _downPosition; + + void _handleTapDown(TapDownDetails details) { + if (isInteractive) { + setState(() { + _downPosition = details.localPosition; + }); + } + } + + void _handleTap([Intent? _]) { + if (!isInteractive) { + return; + } + switch (value) { + case false: + onChanged!(true); + break; + case true: + onChanged!(tristate ? null : false); + break; + case null: + onChanged!(false); + break; + } + context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent()); + } + + void _handleTapEnd([TapUpDetails? _]) { + if (_downPosition != null) { + setState(() { _downPosition = null; }); + } + } + + bool _focused = false; + void _handleFocusHighlightChanged(bool focused) { + if (focused != _focused) { + setState(() { _focused = focused; }); + } + } + + late final Map> _actionMap = >{ + ActivateIntent: CallbackAction(onInvoke: _handleTap), + }; + + /// Typically wraps a `painter` that draws the actual visuals of the + /// Toggleable with logic to toggle it. + /// + /// Consider providing a subclass of [ToggleablePainter] as a `painter`. + /// + /// This method must be called from the [build] method of the [State] class + /// that uses this mixin. The returned [Widget] must be returned from the + /// build method - potentially after wrapping it in other widgets. + Widget buildToggleable({ + FocusNode? focusNode, + Function(bool)? onFocusChange, + bool autofocus = false, + required Size size, + required CustomPainter painter, + }) { + return FocusableActionDetector( + focusNode: focusNode, + autofocus: autofocus, + onFocusChange: onFocusChange, + enabled: isInteractive, + actions: _actionMap, + onShowFocusHighlight: _handleFocusHighlightChanged, + child: GestureDetector( + excludeFromSemantics: !isInteractive, + onTapDown: isInteractive ? _handleTapDown : null, + onTap: isInteractive ? _handleTap : null, + onTapUp: isInteractive ? _handleTapEnd : null, + onTapCancel: isInteractive ? _handleTapEnd : null, + child: Semantics( + enabled: isInteractive, + child: CustomPaint( + size: size, + painter: painter, + ), + ), + ), + ); + } +} + +/// A base class for a [CustomPainter] that may be passed to +/// [ToggleableStateMixin.buildToggleable] to draw the visual representation of +/// a Toggleable. +/// +/// Subclasses must implement the [paint] method to draw the actual visuals of +/// the Toggleable. +abstract class ToggleablePainter extends ChangeNotifier implements CustomPainter { + /// The color that should be used in the active state (i.e., when + /// [ToggleableStateMixin.value] is true). + /// + /// For example, a checkbox should use this color when checked. + Color get activeColor => _activeColor!; + Color? _activeColor; + set activeColor(Color value) { + if (_activeColor == value) { + return; + } + _activeColor = value; + notifyListeners(); + } + + /// The color that should be used in the inactive state (i.e., when + /// [ToggleableStateMixin.value] is false). + /// + /// For example, a checkbox should use this color when unchecked. + Color get inactiveColor => _inactiveColor!; + Color? _inactiveColor; + set inactiveColor(Color value) { + if (_inactiveColor == value) { + return; + } + _inactiveColor = value; + notifyListeners(); + } + + /// The color that should be used for the reaction when [isFocused] is true. + /// + /// Used when the toggleable needs to change the reaction color/transparency, + /// when it has focus. + Color get focusColor => _focusColor!; + Color? _focusColor; + set focusColor(Color value) { + if (value == _focusColor) { + return; + } + _focusColor = value; + notifyListeners(); + } + + /// The [Offset] within the Toggleable at which a pointer touched the Toggleable. + /// + /// This is null if currently no pointer is touching the Toggleable. + /// + /// Usually set to [ToggleableStateMixin.downPosition]. + Offset? get downPosition => _downPosition; + Offset? _downPosition; + set downPosition(Offset? value) { + if (value == _downPosition) { + return; + } + _downPosition = value; + notifyListeners(); + } + + /// True if this toggleable has the input focus. + bool get isFocused => _isFocused!; + bool? _isFocused; + set isFocused(bool? value) { + if (value == _isFocused) { + return; + } + _isFocused = value; + notifyListeners(); + } + + /// Determines whether the toggleable shows as active. + bool get isActive => _isActive!; + bool? _isActive; + set isActive(bool? value) { + if (value == _isActive) { + return; + } + _isActive = value; + notifyListeners(); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; + + @override + bool? hitTest(Offset position) => null; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false; + + @override + String toString() => describeIdentity(this); +} diff --git a/packages/flutter/test/cupertino/checkbox_test.dart b/packages/flutter/test/cupertino/checkbox_test.dart new file mode 100644 index 0000000000..6fc2a2f6f3 --- /dev/null +++ b/packages/flutter/test/cupertino/checkbox_test.dart @@ -0,0 +1,414 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('CupertinoCheckbox semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: false, + onChanged: (bool? b) { }, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + isFocusable: true, + )); + + await tester.pumpWidget( + CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: true, + onChanged: (bool? b) { }, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + isEnabled: true, + hasTapAction: true, + isFocusable: true, + )); + + await tester.pumpWidget( + const CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: false, + onChanged: null, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + // isFocusable is delayed by 1 frame. + isFocusable: true, + )); + + await tester.pump(); + // isFocusable should be false now after the 1 frame delay. + expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + )); + + await tester.pumpWidget( + const CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: true, + onChanged: null, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + )); + + await tester.pumpWidget( + const CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: null, + tristate: true, + onChanged: null, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isCheckStateMixed: true, + )); + + await tester.pumpWidget( + const CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: true, + tristate: true, + onChanged: null, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + )); + + await tester.pumpWidget( + const CupertinoApp ( + home: Center( + child: CupertinoCheckbox( + value: false, + tristate: true, + onChanged: null, + ), + ) + ), + ); + + expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + )); + + handle.dispose(); + }); + + testWidgets('Can wrap CupertinoCheckbox with Semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp( + home: Semantics( + label: 'foo', + textDirection: TextDirection.ltr, + child: CupertinoCheckbox( + value: false, + onChanged: (bool? b) { }, + ), + ), + ), + ); + + expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics( + label: 'foo', + textDirection: TextDirection.ltr, + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + isFocusable: true, + )); + handle.dispose(); + }); + + testWidgets('CupertinoCheckbox tristate: true', (WidgetTester tester) async { + bool? checkBoxValue; + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + tristate: true, + value: checkBoxValue, + onChanged: (bool? value) { + setState(() { + checkBoxValue = value; + }); + }, + ); + }, + ), + ), + ); + + expect(tester.widget(find.byType(CupertinoCheckbox)).value, null); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + + checkBoxValue = true; + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + checkBoxValue = null; + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); + + testWidgets('has semantics for tristate', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoCheckbox( + tristate: true, + value: null, + onChanged: (bool? newValue) { }, + ), + ), + ); + + expect(semantics.nodesWith( + flags: [ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isCheckStateMixed, + ], + actions: [SemanticsAction.tap], + ), hasLength(1)); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoCheckbox( + tristate: true, + value: true, + onChanged: (bool? newValue) { }, + ), + ), + ); + + expect(semantics.nodesWith( + flags: [ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isChecked, + SemanticsFlag.isFocusable, + ], + actions: [SemanticsAction.tap], + ), hasLength(1)); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoCheckbox( + tristate: true, + value: false, + onChanged: (bool? newValue) { }, + ), + ), + ); + + expect(semantics.nodesWith( + flags: [ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [SemanticsAction.tap], + ), hasLength(1)); + + semantics.dispose(); + }); + + testWidgets('has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + bool? checkboxValue = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler(SystemChannels.accessibility, (dynamic message) async { + semanticEvent = message; + }); + final SemanticsTester semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: checkboxValue, + onChanged: (bool? value) { + setState(() { + checkboxValue = value; + }); + }, + ); + }, + ), + ), + ); + + await tester.tap(find.byType(CupertinoCheckbox)); + final RenderObject object = tester.firstRenderObject(find.byType(CupertinoCheckbox)); + + expect(checkboxValue, true); + expect(semanticEvent, { + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': {}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler(SystemChannels.accessibility, null); + semanticsTester.dispose(); + }); + + testWidgets('Checkbox can be toggled by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return CupertinoApp( + home: Center( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: value, + onChanged: enabled ? (bool? newValue) { + setState(() { + value = newValue; + }); + } : null, + autofocus: true, + ); + }), + ), + ); + } + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + // On web, switches don't respond to the enter key. + expect(value, kIsWeb ? isTrue : isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(value, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isTrue); + }); + + testWidgets('Checkbox respects shape and side', (WidgetTester tester) async { + const RoundedRectangleBorder roundedRectangleBorder = + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))); + + const BorderSide side = BorderSide( + width: 4, + color: Color(0xfff44336), + ); + + Widget buildApp() { + return CupertinoApp( + home: Center( + child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: false, + onChanged: (bool? newValue) {}, + shape: roundedRectangleBorder, + side: side, + ); + }), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(CupertinoCheckbox)).shape, roundedRectangleBorder); + expect(tester.widget(find.byType(CupertinoCheckbox)).side, side); + expect( + find.byType(CupertinoCheckbox), + paints + ..drrect( + color: const Color(0xfff44336), + outer: RRect.fromLTRBR(13.0, 13.0, 31.0, 31.0, const Radius.circular(5)), + inner: RRect.fromLTRBR(17.0, 17.0, 27.0, 27.0, const Radius.circular(1)), + ), + ); + }); +}