From 6b73de27bd2f81fbf05a5ee5bcf9659dfebed81a Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Fri, 2 Aug 2024 18:32:17 -0700 Subject: [PATCH] Improve `CupertinoRadio` fidelity (#149703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the following: * Darkens when pressed in light mode * Lightens when pressed in dark mode. * Tests that confirm `CupertinoRadio` is focusable and has correct focus colors * Tests that confirm `CupertinoRadio` uses correct default active/inactive/fill colors * Same look in disabled vs. enabled states as native macOS: | Native macOS | Flutter Before | Flutter After | --- | --- | --- | | radio native | flutter radio before | flutter radio after | * Same look of an unselected radio button in dark mode as native macOS: | Native light mode | Flutter before light mode | Flutter after light mode | Native dark mode | Flutter before dark mode | Flutter after dark mode --- | --- | --- | --- | --- | --- | | native radio light | flutter radio after light | flutter radio light | native radio dark | flutter radio before dark | flutter radio dark | ## Light mode (with focus highlight) | Native light mode | Flutter before light mode | Flutter after light mode | --- | --- | --- | | native radio light mode | radio flutter focus before | radio flutter focus after | ## Dark mode | Native dark mode | Flutter before dark mode | Flutter after dark mode | --- | --- | --- | | native radio dark mode | flutter before radio dark mode | flutter radio dark mode after | ## Disabled light mode | Native | Flutter before | Flutter after | --- | --- | --- | | light disabled radio native | Screenshot 2024-07-30 at 3 13 30 PM | light disabled radio flutter after | ## Disabled dark mode | Native | Flutter before | Flutter after | --- | --- | --- | | dark disabled radio native | Screenshot 2024-07-30 at 3 13 30 PM | dark disabled radio flutter after | `CupertinoRadio` is missing a tristate/mixed state, but [Apple's latest HIG specs discourages its use](https://developer.apple.com/design/human-interface-guidelines/toggles#Radio-buttons). Fixes https://github.com/flutter/flutter/issues/151994 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [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. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat [Data Driven Fixes]: https://github.com/flutter/flutter/wiki/Data-driven-Fixes --------- Co-authored-by: Kate Lovett --- packages/flutter/lib/src/cupertino/radio.dart | 228 ++++++++++-- .../flutter/test/cupertino/radio_test.dart | 350 ++++++++++++++++++ 2 files changed, 540 insertions(+), 38 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/radio.dart b/packages/flutter/lib/src/cupertino/radio.dart index 9ef3b83e44..b32ccc377a 100644 --- a/packages/flutter/lib/src/cupertino/radio.dart +++ b/packages/flutter/lib/src/cupertino/radio.dart @@ -13,6 +13,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; +import 'theme.dart'; // Examples can assume: // late BuildContext context; @@ -30,6 +31,37 @@ const double _kCupertinoFocusColorOpacity = 0.80; const double _kCupertinoFocusColorBrightness = 0.69; const double _kCupertinoFocusColorSaturation = 0.835; +// Eyeballed from a radio on a physical Macbook Pro running macOS version 14.5. +final Color _kDisabledOuterColor = CupertinoColors.white.withOpacity(0.50); +const Color _kDisabledInnerColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(64, 0, 0, 0), + darkColor: Color.fromARGB(64, 255, 255, 255), +); +const Color _kDisabledBorderColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(64, 0, 0, 0), + darkColor: Color.fromARGB(64, 0, 0, 0), +); +const CupertinoDynamicColor _kDefaultBorderColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(255, 209, 209, 214), + darkColor: Color.fromARGB(64, 0, 0, 0), +); +const CupertinoDynamicColor _kDefaultInnerColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: Color.fromARGB(255, 222, 232, 248), +); +const CupertinoDynamicColor _kDefaultOuterColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.activeBlue, + darkColor: Color.fromARGB(255, 50, 100, 215), +); +const double _kPressedOverlayOpacity = 0.15; +const double _kCheckmarkStrokeWidth = 2.0; +const double _kFocusOutlineStrokeWidth = 3.0; +const double _kBorderOutlineStrokeWidth = 0.3; +// In dark mode, the outer color of a radio is an opacity gradient of the +// background color. +const List _kDarkGradientOpacities = [0.14, 0.29]; +const List _kDisabledDarkGradientOpacities = [0.08, 0.14]; + /// A macOS-style radio button. /// /// Used to select between a number of mutually exclusive values. When one radio @@ -250,24 +282,66 @@ class _CupertinoRadioState extends State> with TickerProvid } } + WidgetStateProperty get _defaultOuterColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return CupertinoDynamicColor.resolve(_kDisabledOuterColor, context); + } + if (states.contains(WidgetState.selected)) { + return widget.activeColor ?? CupertinoDynamicColor.resolve(_kDefaultOuterColor, context); + } + return widget.inactiveColor ?? CupertinoColors.white; + }); + } + + WidgetStateProperty get _defaultInnerColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDisabledInnerColor, context); + } + if (states.contains(WidgetState.selected)) { + return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDefaultInnerColor, context); + } + return CupertinoColors.white; + }); + } + + WidgetStateProperty get _defaultBorderColor { + return WidgetStateProperty.resolveWith((Set states) { + if ((states.contains(WidgetState.selected) || states.contains(WidgetState.focused)) + && !states.contains(WidgetState.disabled)) { + return CupertinoColors.transparent; + } + if (states.contains(WidgetState.disabled)) { + return CupertinoDynamicColor.resolve(_kDisabledBorderColor, context); + } + return CupertinoDynamicColor.resolve(_kDefaultBorderColor, context); + }); + } + @override Widget build(BuildContext context) { - final Color effectiveActiveColor = widget.activeColor - ?? CupertinoColors.activeBlue; - final Color effectiveInactiveColor = widget.inactiveColor - ?? CupertinoColors.white; + // Colors need to be resolved in selected and non selected states separately. + final Set activeStates = states..add(WidgetState.selected); + final Set inactiveStates = states..remove(WidgetState.selected); - final Color effectiveFocusOverlayColor = widget.focusColor - ?? HSLColor - .fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity)) - .withLightness(_kCupertinoFocusColorBrightness) - .withSaturation(_kCupertinoFocusColorSaturation) - .toColor(); + // Since the states getter always makes a new set, make a copy to use + // throughout the lifecycle of this build method. + final Set currentStates = states; - final Color effectiveActivePressedOverlayColor = - HSLColor.fromColor(effectiveActiveColor).withLightness(0.45).toColor(); + final Color effectiveActiveColor = _defaultOuterColor.resolve(activeStates); - final Color effectiveFillColor = widget.fillColor ?? CupertinoColors.white; + final Color effectiveInactiveColor = _defaultOuterColor.resolve(inactiveStates); + + final Color effectiveFocusOverlayColor = widget.focusColor ?? HSLColor + .fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity)) + .withLightness(_kCupertinoFocusColorBrightness) + .withSaturation(_kCupertinoFocusColorSaturation) + .toColor(); + + final Color effectiveFillColor = _defaultInnerColor.resolve(currentStates); + + final Color effectiveBorderColor = _defaultBorderColor.resolve(currentStates); final WidgetStateProperty effectiveMouseCursor = WidgetStateProperty.resolveWith((Set states) { @@ -303,14 +377,19 @@ class _CupertinoRadioState extends State> with TickerProvid onFocusChange: onFocusChange, size: _size, painter: _painter + ..position = position + ..reaction = reaction ..focusColor = effectiveFocusOverlayColor ..downPosition = downPosition ..isFocused = focused - ..activeColor = downPosition != null ? effectiveActivePressedOverlayColor : effectiveActiveColor + ..activeColor = effectiveActiveColor ..inactiveColor = effectiveInactiveColor ..fillColor = effectiveFillColor ..value = value - ..checkmarkStyle = widget.useCheckmarkStyle, + ..checkmarkStyle = widget.useCheckmarkStyle + ..isActive = widget.onChanged != null + ..borderColor = effectiveBorderColor + ..brightness = CupertinoTheme.of(context).brightness, ), ); } @@ -347,22 +426,65 @@ class _RadioPainter extends ToggleablePainter { notifyListeners(); } + Brightness? get brightness => _brightness; + Brightness? _brightness; + set brightness(Brightness? value) { + if (_brightness == value) { + return; + } + _brightness = value; + notifyListeners(); + } + + Color get borderColor => _borderColor!; + Color? _borderColor; + set borderColor(Color value) { + if (_borderColor == value) { + return; + } + _borderColor = value; + notifyListeners(); + } + + void _drawPressedOverlay(Canvas canvas, Offset center, double radius) { + final Paint pressedPaint = Paint() + ..color = brightness == Brightness.light + ? CupertinoColors.black.withOpacity(_kPressedOverlayOpacity) + : CupertinoColors.white.withOpacity(_kPressedOverlayOpacity); + canvas.drawCircle(center, radius, pressedPaint); + } + + void _drawFillGradient(Canvas canvas, Offset center, double radius, Color topColor, Color bottomColor) { + final LinearGradient fillGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [topColor, bottomColor], + ); + final Rect circleRect = Rect.fromCircle(center: center, radius: radius); + final Paint gradientPaint = Paint() + ..shader = fillGradient.createShader(circleRect); + canvas.drawPath(Path()..addOval(circleRect), gradientPaint); + } + + void _drawOuterBorder(Canvas canvas, Offset center) { + final Paint borderPaint = Paint() + ..style = PaintingStyle.stroke + ..color = borderColor + ..strokeWidth = _kBorderOutlineStrokeWidth; + canvas.drawCircle(center, _kOuterRadius, borderPaint); + } + @override void paint(Canvas canvas, Size size) { final Offset center = (Offset.zero & size).center; - final Paint paint = Paint() - ..color = inactiveColor - ..style = PaintingStyle.fill - ..strokeWidth = 0.1; - if (checkmarkStyle) { if (value ?? false) { final Path path = Path(); final Paint checkPaint = Paint() ..color = activeColor ..style = PaintingStyle.stroke - ..strokeWidth = 2 + ..strokeWidth = _kCheckmarkStrokeWidth ..strokeCap = StrokeCap.round; final double width = _size.width; final Offset origin = Offset(center.dx - (width/2), center.dy - (width/2)); @@ -377,27 +499,57 @@ class _RadioPainter extends ToggleablePainter { canvas.drawPath(path, checkPaint); } } else { - // Outer border - canvas.drawCircle(center, _kOuterRadius, paint); - - paint.style = PaintingStyle.stroke; - paint.color = CupertinoColors.inactiveGray; - canvas.drawCircle(center, _kOuterRadius, paint); - if (value ?? false) { - paint.style = PaintingStyle.fill; - paint.color = activeColor; - canvas.drawCircle(center, _kOuterRadius, paint); - paint.color = fillColor; - canvas.drawCircle(center, _kInnerRadius, paint); + final Paint outerPaint = Paint()..color = activeColor; + // Draw a gradient in dark mode if the radio is disabled. + if (brightness == Brightness.dark && !isActive) { + _drawFillGradient( + canvas, + center, + _kOuterRadius, + outerPaint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]), + outerPaint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]), + ); + } else { + canvas.drawCircle(center, _kOuterRadius, outerPaint); + } + // The outer circle's opacity changes when the radio is pressed. + if (downPosition != null) { + _drawPressedOverlay(canvas, center, _kOuterRadius); + } + final Paint innerPaint = Paint()..color = fillColor; + canvas.drawCircle(center, _kInnerRadius, innerPaint); + // Draw an outer border if the radio is disabled and selected. + if (!isActive) { + _drawOuterBorder(canvas, center); + } + } else { + final Paint paint = Paint(); + paint.color = isActive ? inactiveColor : _kDisabledOuterColor; + if (brightness == Brightness.dark) { + _drawFillGradient( + canvas, + center, + _kOuterRadius, + paint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]), + paint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]), + ); + } else { + canvas.drawCircle(center, _kOuterRadius, paint); + } + // The entire circle's opacity changes when the radio is pressed. + if (downPosition != null) { + _drawPressedOverlay(canvas, center, _kOuterRadius); + } + _drawOuterBorder(canvas, center); } } - if (isFocused) { - paint.style = PaintingStyle.stroke; - paint.color = focusColor; - paint.strokeWidth = 3.0; - canvas.drawCircle(center, _kOuterRadius + 1.5, paint); + final Paint focusPaint = Paint() + ..style = PaintingStyle.stroke + ..color = focusColor + ..strokeWidth = _kFocusOutlineStrokeWidth; + canvas.drawCircle(center, _kOuterRadius + _kFocusOutlineStrokeWidth / 2, focusPaint); } } } diff --git a/packages/flutter/test/cupertino/radio_test.dart b/packages/flutter/test/cupertino/radio_test.dart index f44369afbc..67fdbbe7c1 100644 --- a/packages/flutter/test/cupertino/radio_test.dart +++ b/packages/flutter/test/cupertino/radio_test.dart @@ -2,6 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -434,6 +440,350 @@ void main() { await gesture.up(); }); + testWidgets('Radio has correct default active/inactive/fill/border colors in light mode', (WidgetTester tester) async { + Widget buildRadio({required int value, required int groupValue}) { + return CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoRadio( + value: value, + groupValue: groupValue, + onChanged: (int? i) { }, + ), + ), + ), + ); + } + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.light_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.light_theme.unselected.png'), + ); + }); + + testWidgets('Radio has correct default active/inactive/fill/border colors in dark mode', (WidgetTester tester) async { + Widget buildRadio({required int value, required int groupValue, bool enabled = true}) { + return CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary( + child: CupertinoRadio( + value: value, + groupValue: groupValue, + onChanged: enabled ? (int? i) { } : null, + ), + ), + ), + ); + } + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.dark_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.dark_theme.unselected.png'), + ); + }); + + testWidgets('Disabled radio has correct default active/inactive/fill/border colors in light mode', (WidgetTester tester) async { + Widget buildRadio({required int value, required int groupValue}) { + return CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoRadio( + value: value, + groupValue: groupValue, + onChanged: null, + ), + ), + ), + ); + } + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.disabled_light_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.disabled_light_theme.unselected.png'), + ); + }); + + testWidgets('Disabled radio has correct default active/inactive/fill/border colors in dark mode', (WidgetTester tester) async { + Widget buildRadio({required int value, required int groupValue}) { + return CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary( + child: CupertinoRadio( + value: value, + groupValue: groupValue, + onChanged: null, + ), + ), + ), + ); + } + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.disabled_dark_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio), + matchesGoldenFile('radio.disabled_dark_theme.unselected.png'), + ); + }); + + testWidgets('Radio can set inactive/active/fill colors', (WidgetTester tester) async { + const Color inactiveBorderColor = Color(0xffd1d1d6); + const Color activeColor = Color(0x0000000A); + const Color fillColor = Color(0x0000000B); + const Color inactiveColor = Color(0x0000000C); + const double innerRadius = 2.975; + const double outerRadius = 7.0; + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 1, + groupValue: 2, + onChanged: (int? i) { }, + activeColor: activeColor, + fillColor: fillColor, + inactiveColor: inactiveColor, + ), + ), + )); + + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: inactiveColor) + ..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor), + reason: 'Unselected radio button should use inactive and border colors', + ); + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 1, + groupValue: 1, + onChanged: (int? i) { }, + activeColor: activeColor, + fillColor: fillColor, + inactiveColor: inactiveColor, + ), + ), + )); + + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: fillColor), + reason: 'Selected radio button should use active and fill colors', + ); + }); + + testWidgets('Radio is slightly darkened when pressed in light mode', (WidgetTester tester) async { + const Color activeInnerColor = Color(0xffffffff); + const Color activeOuterColor = Color(0xff007aff); + const Color inactiveBorderColor = Color(0xffd1d1d6); + const Color inactiveOuterColor = Color(0xffffffff); + const double innerRadius = 2.975; + const double outerRadius = 7.0; + const Color pressedShadowColor = Color(0x26ffffff); + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 1, + groupValue: 2, + onChanged: (int? i) { }, + ), + ), + )); + + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio))); + await tester.pump(); + + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: inactiveOuterColor) + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor), + reason: 'Unselected pressed radio button is slightly darkened', + ); + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 2, + groupValue: 2, + onChanged: (int? i) { }, + ), + ), + )); + + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio))); + await tester.pump(); + + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor), + reason: 'Selected pressed radio button is slightly darkened', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pump(); + }); + + testWidgets('Radio is slightly lightened when pressed in dark mode', (WidgetTester tester) async { + const Color activeInnerColor = Color(0xffffffff); + const Color activeOuterColor = Color(0xff007aff); + const Color inactiveBorderColor = Color(0x40000000); + const double innerRadius = 2.975; + const double outerRadius = 7.0; + const Color pressedShadowColor = Color(0x26ffffff); + + await tester.pumpWidget(CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: CupertinoRadio( + value: 1, + groupValue: 2, + onChanged: (int? i) { }, + ), + ), + )); + + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio))); + await tester.pump(); + + expect( + find.byType(CupertinoRadio), + paints + ..path() + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor), + reason: 'Unselected pressed radio button is slightly lightened', + ); + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 2, + groupValue: 2, + onChanged: (int? i) { }, + ), + ), + )); + + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio))); + await tester.pump(); + + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor), + reason: 'Selected pressed radio button is slightly lightened', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pump(); + }); + + testWidgets('Radio is focusable and has correct focus colors', (WidgetTester tester) async { + const Color activeInnerColor = Color(0xffffffff); + const Color activeOuterColor = Color(0xff007aff); + const Color defaultFocusColor = Color(0xcc6eadf2); + const double innerRadius = 2.975; + const double outerRadius = 7.0; + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final FocusNode node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 1, + groupValue: 1, + onChanged: (int? i) { }, + focusNode: node, + autofocus: true, + ), + ), + )); + + await tester.pump(); + expect(node.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor) + ..circle(strokeWidth: 3.0, style: PaintingStyle.stroke, color: defaultFocusColor), + reason: 'Radio is focusable and shows the default focus color', + ); + }); + + testWidgets('Radio can configure a focus color', (WidgetTester tester) async { + const Color activeInnerColor = Color(0xffffffff); + const Color activeOuterColor = Color(0xff007aff); + const Color focusColor = Color(0x0000000A); + const double innerRadius = 2.975; + const double outerRadius = 7.0; + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final FocusNode node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget(CupertinoApp( + home: Center( + child: CupertinoRadio( + value: 1, + groupValue: 1, + onChanged: (int? i) { }, + focusColor: focusColor, + focusNode: node, + autofocus: true, + ), + ), + )); + + await tester.pump(); + expect(node.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoRadio), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor) + ..circle(strokeWidth: 3.0, style: PaintingStyle.stroke, color: focusColor), + reason: 'Radio configures the color of the focus outline', + ); + }); + testWidgets('Radio configures mouse cursor', (WidgetTester tester) async { await tester.pumpWidget(CupertinoApp( home: Center(