diff --git a/lib/shared/widgets/platform_ui/src/adaptive_chip.dart b/lib/shared/widgets/platform_ui/src/adaptive_chip.dart index 439128ec8..4a77cd561 100644 --- a/lib/shared/widgets/platform_ui/src/adaptive_chip.dart +++ b/lib/shared/widgets/platform_ui/src/adaptive_chip.dart @@ -113,9 +113,12 @@ class AdaptiveChip extends StatelessWidget { @override Widget build(BuildContext context) { final callback = enabled ? onSelected : null; + final chipLabel = semanticLabel == null + ? label + : Semantics(label: semanticLabel, excludeSemantics: true, child: label); final chip = switch (style) { AdaptiveChipStyle.action => ActionChip( - label: label, + label: chipLabel, avatar: avatar, onPressed: enabled ? onPressed : null, backgroundColor: backgroundColor, @@ -127,7 +130,7 @@ class AdaptiveChip extends StatelessWidget { materialTapTargetSize: materialTapTargetSize, ), AdaptiveChipStyle.choice => ChoiceChip( - label: label, + label: chipLabel, selected: selected, onSelected: callback, avatar: avatar, @@ -142,7 +145,7 @@ class AdaptiveChip extends StatelessWidget { materialTapTargetSize: materialTapTargetSize, ), AdaptiveChipStyle.filter => FilterChip( - label: label, + label: chipLabel, selected: selected, onSelected: callback, avatar: avatar, @@ -157,7 +160,7 @@ class AdaptiveChip extends StatelessWidget { materialTapTargetSize: materialTapTargetSize, ), AdaptiveChipStyle.input => InputChip( - label: label, + label: chipLabel, selected: selected, onSelected: callback, avatar: avatar, @@ -175,8 +178,6 @@ class AdaptiveChip extends StatelessWidget { materialTapTargetSize: materialTapTargetSize, ), }; - return semanticLabel == null - ? chip - : Semantics(label: semanticLabel, child: chip); + return chip; } } diff --git a/lib/shared/widgets/platform_ui/src/adaptive_controls.dart b/lib/shared/widgets/platform_ui/src/adaptive_controls.dart index ec328f4b6..4f3eb4c76 100644 --- a/lib/shared/widgets/platform_ui/src/adaptive_controls.dart +++ b/lib/shared/widgets/platform_ui/src/adaptive_controls.dart @@ -193,11 +193,15 @@ class AdaptiveButton extends StatelessWidget { @override Widget build(BuildContext context) { final button = _buildButton(context); - return semanticLabel == null + return semanticLabel == null || !_canUseNative(context) ? button : Semantics(label: semanticLabel, child: button); } + Widget _semanticContent(Widget content) => semanticLabel == null + ? content + : Semantics(label: semanticLabel, excludeSemantics: true, child: content); + Widget _buildButton(BuildContext context) { if (_canUseNative(context)) { final resolvedPadding = padding?.resolve(Directionality.of(context)); @@ -307,7 +311,7 @@ class AdaptiveButton extends StatelessWidget { borderRadius: resolvedRadius, color: background, minimumSize: minSize ?? Size(0, _defaultHeight), - child: _content(fallbackColor: foreground), + child: _semanticContent(_content(fallbackColor: foreground)), ); final borderSide = style == AdaptiveButtonStyle.bordered ? BorderSide(color: primary) @@ -368,13 +372,17 @@ class AdaptiveButton extends StatelessWidget { : borderRadius == null ? null : RoundedRectangleBorder(borderRadius: effectiveRadius); - final primaryContent = _content( - fallbackColor: textColor ?? colorScheme.onPrimary, - wrapCustomChild: false, + final primaryContent = _semanticContent( + _content( + fallbackColor: textColor ?? colorScheme.onPrimary, + wrapCustomChild: false, + ), ); - final accentContent = _content( - fallbackColor: textColor ?? color ?? colorScheme.primary, - wrapCustomChild: false, + final accentContent = _semanticContent( + _content( + fallbackColor: textColor ?? color ?? colorScheme.primary, + wrapCustomChild: false, + ), ); return switch (style) { AdaptiveButtonStyle.filled => ElevatedButton( diff --git a/lib/shared/widgets/platform_ui/src/adaptive_value_segmented_control.dart b/lib/shared/widgets/platform_ui/src/adaptive_value_segmented_control.dart index 52dc37087..2d91abc24 100644 --- a/lib/shared/widgets/platform_ui/src/adaptive_value_segmented_control.dart +++ b/lib/shared/widgets/platform_ui/src/adaptive_value_segmented_control.dart @@ -9,17 +9,16 @@ class AdaptiveSegment { required this.label, this.icon, this.enabled = true, - this.sfSymbol, this.semanticLabel, }); final T value; final Widget label; final Widget? icon; - final String? sfSymbol; final String? semanticLabel; final bool enabled; } +/// Requires at least two segments with distinct values on every platform. class AdaptiveValueSegmentedControl extends StatelessWidget { const AdaptiveValueSegmentedControl({ super.key, @@ -43,8 +42,31 @@ class AdaptiveValueSegmentedControl extends StatelessWidget { onChanged!(next); } + Widget _label(AdaptiveSegment segment) => Semantics( + label: segment.semanticLabel, + enabled: segment.enabled && onChanged != null, + excludeSemantics: segment.semanticLabel != null, + child: segment.label, + ); + + Widget _cupertinoLabel(AdaptiveSegment segment) { + final icon = segment.icon; + final label = _label(segment); + return icon == null + ? label + : Row( + mainAxisSize: MainAxisSize.min, + children: [icon, const SizedBox(width: 6), label], + ); + } + @override Widget build(BuildContext context) { + if (segments.length < 2 || + segments.map((segment) => segment.value).toSet().length != + segments.length) { + throw ArgumentError('Segments require at least two distinct values.'); + } final platform = Theme.of(context).platform; if (platform == TargetPlatform.iOS || platform == TargetPlatform.macOS) { return CupertinoSlidingSegmentedControl( @@ -57,7 +79,8 @@ class AdaptiveValueSegmentedControl extends StatelessWidget { if (next != null) _select(next); }, children: { - for (final segment in segments) segment.value: segment.label, + for (final segment in segments) + segment.value: _cupertinoLabel(segment), }, ); } @@ -69,7 +92,7 @@ class AdaptiveValueSegmentedControl extends StatelessWidget { for (final segment in segments) ButtonSegment( value: segment.value, - label: segment.label, + label: _label(segment), icon: segment.icon, enabled: segment.enabled, ), diff --git a/test/shared/widgets/platform_ui/adaptive_controls_facades_test.dart b/test/shared/widgets/platform_ui/adaptive_controls_facades_test.dart index 285884149..94f27539f 100644 --- a/test/shared/widgets/platform_ui/adaptive_controls_facades_test.dart +++ b/test/shared/widgets/platform_ui/adaptive_controls_facades_test.dart @@ -8,55 +8,228 @@ void main() { testWidgets( 'Android chips keep selection, disabled state and separate deletion', (tester) async { - PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.android; - var selected = false; - var changes = 0; - var deletes = 0; - var enabled = true; - late StateSetter update; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: StatefulBuilder( - builder: (context, setState) { - update = setState; - return AdaptiveChip.input( - label: const Text('Tag'), - selected: selected, - enabled: enabled, - onSelected: (value) => setState(() { - selected = value; - changes++; - }), - onDeleted: () => deletes++, - ); - }, + final semantics = tester.ensureSemantics(); + try { + PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.android; + var selected = false; + var changes = 0; + var deletes = 0; + var enabled = true; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + update = setState; + return AdaptiveChip.input( + label: const Text('Tag'), + semanticLabel: 'Tag action', + selected: selected, + enabled: enabled, + onSelected: (value) => setState(() { + selected = value; + changes++; + }), + onDeleted: () => deletes++, + ); + }, + ), ), ), - ), - ); - await tester.tap(find.text('Tag')); - await tester.pumpAndSettle(); - expect(selected, isTrue); - expect(changes, 1); - await tester.tap( - find.byWidgetPredicate( - (widget) => widget is Tooltip && widget.message == 'Delete', - ), - ); - expect(deletes, 1); - expect(changes, 1); - update(() => enabled = false); - await tester.pumpAndSettle(); - await tester.tap(find.text('Tag')); - expect(changes, 1); - expect( - tester.widget(find.byType(InputChip)).onDeleted, - isNull, - ); + ); + await tester.tap(find.text('Tag')); + await tester.pumpAndSettle(); + expect(selected, isTrue); + expect(changes, 1); + expect( + tester.getSemantics(find.bySemanticsLabel('Tag action')), + isSemantics( + label: 'Tag action', + isButton: true, + isSelected: true, + hasTapAction: true, + ), + ); + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == 'Delete', + ), + ); + expect(deletes, 1); + expect(changes, 1); + update(() => enabled = false); + await tester.pumpAndSettle(); + await tester.tap(find.text('Tag')); + expect(changes, 1); + expect( + tester.widget(find.byType(InputChip)).onDeleted, + isNull, + ); + } finally { + semantics.dispose(); + } }, ); + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('button names retain activation on ${platform.name}', ( + tester, + ) async { + PlatformUiCapabilities.debugPlatformOverride = platform; + PlatformUiCapabilities.debugIOSMajorVersionOverride = 25; + final semantics = tester.ensureSemantics(); + try { + var presses = 0; + var enabled = true; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + update = setState; + return AdaptiveButton( + label: 'Visible', + semanticLabel: 'Submit', + enabled: enabled, + onPressed: () => presses++, + ); + }, + ), + ), + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Submit')), + isSemantics(label: 'Submit', isButton: true, hasTapAction: true), + ); + await tester.tap(find.text('Visible')); + await tester.pumpAndSettle(); + expect(presses, 1); + update(() => enabled = false); + await tester.pumpAndSettle(); + expect( + tester.getSemantics(find.bySemanticsLabel('Submit')), + isSemantics(label: 'Submit', isButton: true), + ); + await tester.tap(find.text('Visible')); + await tester.pumpAndSettle(); + expect(presses, 1); + } finally { + semantics.dispose(); + } + }); + + testWidgets( + 'segments reject missing or duplicate choices on ${platform.name}', + (tester) async { + for (final segments in >>[ + [], + [const AdaptiveSegment(value: 1, label: Text('Only'))], + [ + const AdaptiveSegment(value: 1, label: Text('One')), + const AdaptiveSegment(value: 1, label: Text('Duplicate')), + ], + ]) { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: AdaptiveValueSegmentedControl( + segments: segments, + value: 1, + onChanged: (_) {}, + ), + ), + ), + ); + expect(tester.takeException(), isArgumentError); + } + }, + ); + + testWidgets( + 'typed segments preserve labels and selection rules on ${platform.name}', + (tester) async { + final semantics = tester.ensureSemantics(); + try { + var selected = 1; + var enabled = true; + final changes = []; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + update = setState; + return AdaptiveValueSegmentedControl( + value: selected, + segments: const [ + AdaptiveSegment( + value: 1, + label: Text('One'), + icon: Icon(Icons.looks_one), + semanticLabel: 'First option', + ), + AdaptiveSegment( + value: 2, + label: Text('Two'), + semanticLabel: 'Second option', + ), + AdaptiveSegment( + value: 3, + label: Text('Disabled'), + enabled: false, + ), + ], + onChanged: enabled + ? (next) => setState(() { + selected = next; + changes.add(next); + }) + : null, + ); + }, + ), + ), + ), + ); + expect(find.bySemanticsLabel('First option'), findsOneWidget); + expect(find.byIcon(Icons.looks_one), findsOneWidget); + expect(find.bySemanticsLabel('Second option'), findsOneWidget); + expect( + tester.getSemantics(find.bySemanticsLabel('Disabled')), + isSemantics(isButton: true, isEnabled: false), + ); + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + expect(selected, 2); + expect(changes, [2]); + expect( + tester.getSemantics(find.bySemanticsLabel('Second option')), + isSemantics(isSelected: true), + ); + await tester.tap(find.text('Two')); + await tester.tap(find.text('Disabled')); + await tester.pumpAndSettle(); + expect(changes, [2]); + update(() => enabled = false); + await tester.pumpAndSettle(); + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + expect(selected, 2); + expect(changes, [2]); + } finally { + semantics.dispose(); + } + }, + ); + } + testWidgets('progress carries a known value and accessibility text', ( tester, ) async {