diff --git a/packages/datadog_session_replay/example/golden_test/goldens/masked_cupertino_sliders.png b/packages/datadog_session_replay/example/golden_test/goldens/masked_cupertino_sliders.png new file mode 100644 index 00000000..36dde8f6 Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/masked_cupertino_sliders.png differ diff --git a/packages/datadog_session_replay/example/golden_test/goldens/masked_material_sliders.png b/packages/datadog_session_replay/example/golden_test/goldens/masked_material_sliders.png new file mode 100644 index 00000000..096d45ab Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/masked_material_sliders.png differ diff --git a/packages/datadog_session_replay/example/golden_test/goldens/unmasked_cupertino_sliders.png b/packages/datadog_session_replay/example/golden_test/goldens/unmasked_cupertino_sliders.png new file mode 100644 index 00000000..b77cce64 Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/unmasked_cupertino_sliders.png differ diff --git a/packages/datadog_session_replay/example/golden_test/goldens/unmasked_material_sliders.png b/packages/datadog_session_replay/example/golden_test/goldens/unmasked_material_sliders.png new file mode 100644 index 00000000..d3fc889e Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/unmasked_material_sliders.png differ diff --git a/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart b/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart index c924eacf..69298deb 100644 --- a/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart +++ b/packages/datadog_session_replay/example/golden_test/simple_widget_golden_test.dart @@ -471,4 +471,196 @@ void main() { ); await snapshotTest(tester, recorder, fixture); }); + + testWidgets('unmasked material sliders', (tester) async { + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Unmasked Material Sliders')), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // year2023 = true (default M3 round thumb) — min, mid, max. + Slider(value: 0.0, onChanged: (_) {}), + Slider(value: 0.5, onChanged: (_) {}), + Slider(value: 1.0, onChanged: (_) {}), + // Custom colors. + Slider( + value: 0.5, + onChanged: (_) {}, + activeColor: Colors.green, + inactiveColor: Colors.yellow, + thumbColor: Colors.red, + ), + // With secondary track value. + Slider( + value: 0.3, + secondaryTrackValue: 0.7, + onChanged: (_) {}, + ), + // Discrete slider with tick marks. + Slider(value: 0.4, divisions: 5, onChanged: (_) {}), + // M3-2024 (year2023 = false) — handle thumb + gap + stop indicator. + Slider( + // ignore: deprecated_member_use + year2023: false, + value: 0.4, + onChanged: (_) {}, + ), + Slider( + // ignore: deprecated_member_use + year2023: false, + value: 0.4, + divisions: 5, + onChanged: (_) {}, + ), + // Disabled. + Slider(value: 0.5, onChanged: null), + ], + ), + ), + ), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('unmasked cupertino sliders', (tester) async { + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Unmasked Cupertino Sliders')), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoSlider(value: 0.0, onChanged: (_) {}), + CupertinoSlider(value: 0.5, onChanged: (_) {}), + CupertinoSlider(value: 1.0, onChanged: (_) {}), + CupertinoSlider( + value: 0.5, + onChanged: (_) {}, + activeColor: CupertinoColors.systemPurple, + thumbColor: CupertinoColors.systemPurple, + ), + CupertinoSlider(value: 0.5, onChanged: null), + CupertinoSlider(value: 0.5, divisions: 5, onChanged: (_) {}), + ], + ), + ), + ), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('masked material sliders', (tester) async { + recorder = SessionReplayRecorder( + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAllInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + recorder.updateContext(context); + + // With maskAllInputs every thumb should be anchored at the track midpoint + // regardless of the supplied value (0.0, 0.5, 1.0 should all look the same). + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Masked Material Sliders')), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // year2023 = true (default M3 round thumb) — min, mid, max. + Slider(value: 0.0, onChanged: (_) {}), + Slider(value: 0.5, onChanged: (_) {}), + Slider(value: 1.0, onChanged: (_) {}), + // Custom colors. + Slider( + value: 0.5, + onChanged: (_) {}, + activeColor: Colors.green, + inactiveColor: Colors.yellow, + thumbColor: Colors.red, + ), + // With secondary track value. + Slider( + value: 0.3, + secondaryTrackValue: 0.7, + onChanged: (_) {}, + ), + // Discrete slider with tick marks. + Slider(value: 0.4, divisions: 5, onChanged: (_) {}), + // M3-2024 (year2023 = false) — handle thumb + gap + stop indicator. + Slider( + // ignore: deprecated_member_use + year2023: false, + value: 0.4, + onChanged: (_) {}, + ), + Slider( + // ignore: deprecated_member_use + year2023: false, + value: 0.4, + divisions: 5, + onChanged: (_) {}, + ), + // Disabled. + Slider(value: 0.5, onChanged: null), + ], + ), + ), + ), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('masked cupertino sliders', (tester) async { + recorder = SessionReplayRecorder( + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAllInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + recorder.updateContext(context); + + // With maskAllInputs every thumb should be anchored at the track midpoint + // regardless of the supplied value (0.0, 0.5, 1.0 should all look the same). + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Masked Cupertino Sliders')), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoSlider(value: 0.0, onChanged: (_) {}), + CupertinoSlider(value: 0.5, onChanged: (_) {}), + CupertinoSlider(value: 1.0, onChanged: (_) {}), + CupertinoSlider( + value: 0.5, + onChanged: (_) {}, + activeColor: CupertinoColors.systemPurple, + thumbColor: CupertinoColors.systemPurple, + ), + CupertinoSlider(value: 0.5, onChanged: null), + CupertinoSlider(value: 0.5, divisions: 5, onChanged: (_) {}), + ], + ), + ), + ), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); } diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_slider_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_slider_recorder.dart new file mode 100644 index 00000000..3c6b480b --- /dev/null +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_slider_recorder.dart @@ -0,0 +1,210 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; + +import '../../../sr_data_models.dart'; +import '../../capture_node.dart'; +import '../../recorder.dart'; +import '../../view_tree_snapshot.dart'; +import '../material_widgets/slider_recorder.dart'; +import '../recording_extensions.dart'; +import 'cupertino_recording_extensions.dart'; + +const double _padding = 8.0; +const double _thumbRadius = 14.0; +const double _trackHalfHeight = 1.0; + +typedef _SliderGeometry = ({ + Rect inactiveTrack, + Rect activeTrack, + Rect thumb, +}); + +/// Detects [CupertinoSlider] widgets and renders them in Session Replay. +class CupertinoSliderRecorder implements ElementRecorder { + final KeyGenerator keyGenerator; + + const CupertinoSliderRecorder(this.keyGenerator); + + @override + bool accepts(Widget widget) => widget is CupertinoSlider; + + @override + CaptureNodeSemantics? captureSemantics( + Element element, + CapturedViewAttributes attributes, + TreeCapturePrivacy capturePrivacy, + ) { + final widget = element.widget; + if (widget is! CupertinoSlider) return null; + + // Resolves for privacy settings + final bool isMasked = capturePrivacy.shouldMaskInputs; + + final Color activeColor = _getActiveColor(element: element, widget: widget); + final Color trackColor = _getTrackColor(element: element); + final Color thumbColor = _getThumbColor(element: element, widget: widget); + + final _SliderGeometry geometry = _getSliderGeometry( + widget: widget, + isMasked: isMasked, + bounds: attributes.paintBounds, + scaleX: attributes.scaleX, + scaleY: attributes.scaleY, + ); + + final inactiveTrackKey = + keyGenerator.keyForElement(element, wireframeId: 0); + final activeTrackKey = keyGenerator.keyForElement(element, wireframeId: 1); + final thumbKey = keyGenerator.keyForElement(element, wireframeId: 2); + + final node = CupertinoSliderNode( + attributes, + inactiveTrackWireframeId: inactiveTrackKey, + activeTrackWireframeId: activeTrackKey, + thumbWireframeId: thumbKey, + inactiveTrackRect: geometry.inactiveTrack, + activeTrackRect: geometry.activeTrack, + thumbRect: geometry.thumb, + trackColor: trackColor, + activeColor: activeColor, + thumbColor: thumbColor, + ); + + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [node], + ); + } + + Color _getActiveColor({ + required Element element, + required CupertinoSlider widget, + }) { + final base = widget.activeColor ?? CupertinoTheme.of(element).primaryColor; + return base.resolveColor(element); + } + + Color _getTrackColor({required Element element}) { + return CupertinoColors.systemFill.resolveColor(element); + } + + Color _getThumbColor({ + required Element element, + required CupertinoSlider widget, + }) { + return widget.thumbColor.resolveColor(element); + } + + _SliderGeometry _getSliderGeometry({ + required CupertinoSlider widget, + required bool isMasked, + required Rect bounds, + required double scaleX, + required double scaleY, + }) { + // Uniform scale preserves circles/pills and ensures dimensions fit within + // anisotropic bounds. + final double scale = math.min(scaleX, scaleY); + + final double padding = _padding * scale; + final double thumbRadius = _thumbRadius * scale; + final double trackHalfHeight = _trackHalfHeight * scale; + + final double trackLeft = bounds.left + padding; + final double trackRight = bounds.right - padding; + final double trackCenterY = bounds.center.dy; + final double trackTop = trackCenterY - trackHalfHeight; + final double trackBottom = trackCenterY + trackHalfHeight; + + final double range = widget.max - widget.min; + final double valueRatio; + if (isMasked) { + valueRatio = 0.5; + } else { + valueRatio = range == 0 + ? 0.0 + : ((widget.value - widget.min) / range).clamp(0.0, 1.0).toDouble(); + } + final double thumbTravel = (trackRight - trackLeft) - 2 * thumbRadius; + final double thumbCenterX = + trackLeft + thumbRadius + thumbTravel * valueRatio; + + final Rect inactiveTrack = Rect.fromLTRB( + trackLeft, + trackTop, + trackRight, + trackBottom, + ); + final Rect activeTrack = Rect.fromLTRB( + trackLeft, + trackTop, + math.max(trackLeft, thumbCenterX), + trackBottom, + ); + final Rect thumb = Rect.fromCircle( + center: Offset(thumbCenterX, trackCenterY), + radius: thumbRadius, + ); + + return ( + inactiveTrack: inactiveTrack, + activeTrack: activeTrack, + thumb: thumb, + ); + } +} + +/// Holds the resolved visual properties of a [CupertinoSlider] and builds the +/// corresponding [SRShapeWireframe]s: inactive track segment, active track +/// segment, then the circular thumb on top. +@immutable +class CupertinoSliderNode extends CaptureNode { + final int inactiveTrackWireframeId; + final int activeTrackWireframeId; + final int thumbWireframeId; + final Rect inactiveTrackRect; + final Rect activeTrackRect; + final Rect thumbRect; + final Color trackColor; + final Color activeColor; + final Color thumbColor; + + const CupertinoSliderNode( + super.attributes, { + required this.inactiveTrackWireframeId, + required this.activeTrackWireframeId, + required this.thumbWireframeId, + required this.inactiveTrackRect, + required this.activeTrackRect, + required this.thumbRect, + required this.trackColor, + required this.activeColor, + required this.thumbColor, + }); + + @override + List buildWireframes() { + return [ + ShapeWireframeBuilder.shape( + id: inactiveTrackWireframeId, + rect: inactiveTrackRect, + color: trackColor, + ), + ShapeWireframeBuilder.shape( + id: activeTrackWireframeId, + rect: activeTrackRect, + color: activeColor, + ), + ShapeWireframeBuilder.shape( + id: thumbWireframeId, + rect: thumbRect, + color: thumbColor, + ), + ]; + } +} diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/slider_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/slider_recorder.dart new file mode 100644 index 00000000..3d0cd77e --- /dev/null +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/slider_recorder.dart @@ -0,0 +1,693 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../../extensions.dart'; +import '../../../sr_data_models.dart'; +import '../../capture_node.dart'; +import '../../recorder.dart'; +import '../../view_tree_snapshot.dart'; +import '../recording_extensions.dart'; + +const Size _handleThumbSize = Size(4.0, 44.0); +const double _roundedThumbDiameter = 20.0; + +enum _SliderThumbStyle { round, handle } + +typedef _SliderThumbGeometry = ({ + Rect rect, + BorderRadius borderRadius, + _SliderThumbStyle style, +}); + +typedef _SliderTrackSegmentGeometry = ({ + Rect rect, + BorderRadius borderRadius, +}); + +typedef _SliderGeometry = ({ + _SliderThumbGeometry thumb, + _SliderTrackSegmentGeometry inactiveTrack, + _SliderTrackSegmentGeometry activeTrack, + _SliderTrackSegmentGeometry? secondaryActiveTrack, + Rect? gap, + Rect? stopIndicator, + List activeTickMarks, + List inactiveTickMarks, +}); + +/// Detects 'Slider' widgets and places an slider +/// in SessionReplay. +class SliderRecorder implements ElementRecorder { + final KeyGenerator keyGenerator; + + const SliderRecorder(this.keyGenerator); + + @override + bool accepts(Widget widget) => widget is Slider; + + @override + CaptureNodeSemantics? captureSemantics( + Element element, + CapturedViewAttributes attributes, + TreeCapturePrivacy capturePrivacy, + ) { + final widget = element.widget; + if (widget is! Slider) return null; + + // Check for cupertino slider style + { + bool isCupertinoAdaptive = false; + element.visitChildElements((child) { + if (child.widget is CupertinoSlider) isCupertinoAdaptive = true; + }); + if (isCupertinoAdaptive) return null; + } + + // Resolves for privacy settings + final bool isMasked = capturePrivacy.shouldMaskInputs; + + // Resolve slider theme + final ThemeData theme = Theme.of(element); + final SliderThemeData sliderTheme = SliderTheme.of(element); + + final bool year2023 = switch (theme.useMaterial3) { + true => widget.year2023 ?? sliderTheme.year2023 ?? true, + false => false, + }; + + final isEnabled = widget.onChanged != null; + + final Color activeColor = _getActiveColor( + widget: widget, + isEnabled: isEnabled, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + ); + final Color inactiveColor = _getInactiveColor( + widget: widget, + isEnabled: isEnabled, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + ); + final Color secondaryActiveColor = _getSecondaryActiveColor( + widget: widget, + isEnabled: isEnabled, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + ); + final Color thumbColor = _getThumbColor( + widget: widget, + isEnabled: isEnabled, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + ); + final Color activeTickMarkColor = _getActiveTickMarkColor( + widget: widget, + isEnabled: isEnabled, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + ); + final Color inactiveTickMarkColor = _getInactiveTickMarkColor( + widget: widget, + isEnabled: isEnabled, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + ); + + final _SliderGeometry geometry = _getSliderGeometry( + widget: widget, + theme: theme, + sliderTheme: sliderTheme, + year2023: year2023, + isMasked: isMasked, + bounds: attributes.paintBounds, + scaleX: attributes.scaleX, + scaleY: attributes.scaleY, + ); + + // We only need the background color to create a fake gap, between the Track and the Thumb, + // so we only grab it when geometry.gap is set. This will be true for Material 3, or when + // year2023 is false. + final Color? gapColor = + geometry.gap != null ? _findBackgroundColor(element, theme) : null; + + final int tickCount = + geometry.activeTickMarks.length + geometry.inactiveTickMarks.length; + + final inactiveTrackKey = + keyGenerator.keyForElement(element, wireframeId: 0); + final secondaryActiveTrackKey = + keyGenerator.keyForElement(element, wireframeId: 1); + final activeTrackKey = keyGenerator.keyForElement(element, wireframeId: 2); + final List tickMarkKeys = [ + for (int i = 0; i < tickCount; i++) + keyGenerator.keyForElement(element, wireframeId: 3 + i), + ]; + final gapKey = + keyGenerator.keyForElement(element, wireframeId: 3 + tickCount); + final stopIndicatorKey = + keyGenerator.keyForElement(element, wireframeId: 4 + tickCount); + final thumbKey = + keyGenerator.keyForElement(element, wireframeId: 5 + tickCount); + + final node = SliderNode( + attributes, + inactiveTrackWireframeId: inactiveTrackKey, + secondaryActiveTrackWireframeId: secondaryActiveTrackKey, + activeTrackWireframeId: activeTrackKey, + tickMarkWireframeIds: tickMarkKeys, + gapWireframeId: gapKey, + stopIndicatorWireframeId: stopIndicatorKey, + thumbWireframeId: thumbKey, + inactiveTrackRect: geometry.inactiveTrack.rect, + secondaryActiveTrackRect: geometry.secondaryActiveTrack?.rect, + activeTrackRect: geometry.activeTrack.rect, + activeTickMarkRects: geometry.activeTickMarks, + inactiveTickMarkRects: geometry.inactiveTickMarks, + gapRect: geometry.gap, + stopIndicatorRect: geometry.stopIndicator, + thumbRect: geometry.thumb.rect, + inactiveColor: inactiveColor, + secondaryActiveColor: secondaryActiveColor, + activeColor: activeColor, + activeTickMarkColor: activeTickMarkColor, + inactiveTickMarkColor: inactiveTickMarkColor, + gapColor: gapColor, + thumbColor: thumbColor, + ); + + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [node], + ); + } + + // All color values mirror Flutter’s own implementation, + // as declared in package:flutter/src/material/slider.dart. + + Color _getActiveColor({ + required Slider widget, + required bool isEnabled, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + }) { + if (isEnabled) { + return widget.activeColor ?? + sliderTheme.activeTrackColor ?? + theme.colorScheme.primary; + } + Color? disabledColor = sliderTheme.disabledActiveTrackColor; + if (disabledColor != null) return disabledColor; + if (theme.useMaterial3) { + return theme.colorScheme.onSurface.withValues(alpha: 0.38); + } + return theme.colorScheme.onSurface.withValues(alpha: 0.32); + } + + Color _getInactiveColor({ + required Slider widget, + required bool isEnabled, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + }) { + if (isEnabled) { + Color? inactiveColor = + widget.inactiveColor ?? sliderTheme.inactiveTrackColor; + if (inactiveColor != null) return inactiveColor; + if (theme.useMaterial3) { + if (year2023) return theme.colorScheme.surfaceContainerHighest; + return theme.colorScheme.secondaryContainer; + } + return theme.colorScheme.primary.withValues(alpha: 0.24); + } + return theme.colorScheme.onSurface.withValues(alpha: 0.12); + } + + Color _getSecondaryActiveColor({ + required Slider widget, + required bool isEnabled, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + }) { + if (isEnabled) { + return widget.secondaryActiveColor ?? + sliderTheme.secondaryActiveTrackColor ?? + theme.colorScheme.primary.withValues(alpha: 0.54); + } + Color? disabledColor = sliderTheme.disabledSecondaryActiveTrackColor; + if (disabledColor != null) return disabledColor; + if (theme.useMaterial3 && !year2023) { + return theme.colorScheme.onSurface.withValues(alpha: 0.38); + } + return theme.colorScheme.onSurface.withValues(alpha: 0.12); + } + + Color _getThumbColor({ + required Slider widget, + required bool isEnabled, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + }) { + if (isEnabled) { + return widget.thumbColor ?? + widget.activeColor ?? + sliderTheme.thumbColor ?? + theme.colorScheme.primary; + } + Color? disabledColor = sliderTheme.disabledThumbColor; + if (disabledColor != null) return disabledColor; + if (theme.useMaterial3 && !year2023) { + return theme.colorScheme.onSurface.withValues(alpha: 0.38); + } + return Color.alphaBlend( + theme.colorScheme.onSurface.withValues(alpha: 0.38), + theme.colorScheme.surface, + ); + } + + _SliderGeometry _getSliderGeometry({ + required Slider widget, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + required bool isMasked, + required Rect bounds, + required double scaleX, + required double scaleY, + }) { + final bool isGapped = theme.useMaterial3 && !year2023; + + // Uniform scale preserves circles/pills and ensures dimensions fit within + // anisotropic bounds. + final double scale = math.min(scaleX, scaleY); + + final double trackHeight = + (sliderTheme.trackHeight ?? (isGapped ? 16.0 : 4.0)) * scale; + + final _SliderThumbStyle thumbStyle; + final Size thumbSize; + if (isGapped) { + thumbStyle = _SliderThumbStyle.handle; + final Size logicalThumbSize = + sliderTheme.thumbSize?.resolve({}) ?? _handleThumbSize; + thumbSize = Size( + logicalThumbSize.width * scale, + logicalThumbSize.height * scale, + ); + } else { + thumbStyle = _SliderThumbStyle.round; + thumbSize = + Size(_roundedThumbDiameter * scale, _roundedThumbDiameter * scale); + } + + final double overlayWidth = 48.0 * scale; + final double horizontalInset; + if (sliderTheme.padding != null) { + horizontalInset = 0.0; + } else { + horizontalInset = math.max(thumbSize.width, overlayWidth) / 2; + } + + final double trackLeft = bounds.left + horizontalInset; + final double trackRight = bounds.right - horizontalInset; + final double trackTop = bounds.center.dy - trackHeight / 2; + final double trackBottom = trackTop + trackHeight; + final double trackWidth = trackRight - trackLeft; + + final Radius trackEndRadius = Radius.circular(trackHeight / 2); + + final double range = widget.max - widget.min; + // When inputs are masked, anchor the thumb at the center of the track so + // the recorded replay doesn't leak the actual value. + final double valueRatio; + if (isMasked) { + valueRatio = 0.5; + } else { + valueRatio = range == 0 + ? 0.0 + : ((widget.value - widget.min) / range).clamp(0.0, 1.0).toDouble(); + } + final double thumbTravel = trackWidth - 2 * trackEndRadius.x; + final double thumbCenterX = + trackLeft + trackEndRadius.x + thumbTravel * valueRatio; + + final _SliderTrackSegmentGeometry inactiveTrack = ( + rect: Rect.fromLTRB(trackLeft, trackTop, trackRight, trackBottom), + borderRadius: BorderRadius.all(trackEndRadius), + ); + + final _SliderTrackSegmentGeometry activeTrack = ( + rect: Rect.fromLTRB( + trackLeft, + trackTop, + math.max(trackLeft, thumbCenterX), + trackBottom, + ), + borderRadius: BorderRadius.all(trackEndRadius), + ); + + _SliderTrackSegmentGeometry? secondaryActiveTrack; + final double? secValue = widget.secondaryTrackValue; + if (secValue != null) { + final clampedSec = secValue.clamp(widget.min, widget.max); + final secRatio = range == 0 ? 0.0 : (clampedSec - widget.min) / range; + final secX = trackLeft + trackWidth * secRatio; + if (secX > trackLeft) { + secondaryActiveTrack = ( + rect: + Rect.fromLTRB(trackLeft, trackTop, secX.toDouble(), trackBottom), + borderRadius: BorderRadius.all(trackEndRadius), + ); + } + } + + final Rect thumbRect = Rect.fromCenter( + center: Offset(thumbCenterX, bounds.center.dy), + width: thumbSize.width, + height: thumbSize.height, + ); + final _SliderThumbGeometry thumb = ( + rect: thumbRect, + borderRadius: + BorderRadius.all(Radius.circular(thumbSize.shortestSide / 2)), + style: thumbStyle, + ); + + // M3-2024 gap: a single bg-colored band centered on the thumb that + // overpaints the active/inactive tracks to simulate the visual gap. + Rect? gap; + Rect? stopIndicator; + if (isGapped) { + final double trackGap = (sliderTheme.trackGap ?? 6.0) * scale; + gap = Rect.fromCenter( + center: Offset(thumbCenterX, bounds.center.dy), + width: thumbSize.width + 2 * trackGap, + height: trackHeight, + ); + + final double stopRadius = 2.0 * scale; + stopIndicator = Rect.fromCenter( + center: Offset(trackRight - trackEndRadius.x, bounds.center.dy), + width: stopRadius * 2, + height: stopRadius * 2, + ); + } + + // Tick marks for discrete (`divisions`) sliders + final List activeTickMarks = []; + final List inactiveTickMarks = []; + final int? divisions = widget.divisions; + if (divisions != null && divisions > 0) { + final double tickRadius = (year2023 ? 1.0 : 2.0) * scale; + for (int i = 0; i <= divisions; i++) { + final double tickX = + trackLeft + trackEndRadius.x + thumbTravel * (i / divisions); + final Rect tickRect = Rect.fromCenter( + center: Offset(tickX, bounds.center.dy), + width: tickRadius * 2, + height: tickRadius * 2, + ); + if (tickX <= thumbCenterX) { + activeTickMarks.add(tickRect); + } else { + inactiveTickMarks.add(tickRect); + } + } + } + + return ( + thumb: thumb, + inactiveTrack: inactiveTrack, + activeTrack: activeTrack, + secondaryActiveTrack: secondaryActiveTrack, + gap: gap, + stopIndicator: stopIndicator, + activeTickMarks: activeTickMarks, + inactiveTickMarks: inactiveTickMarks, + ); + } + + Color _getActiveTickMarkColor({ + required Slider widget, + required bool isEnabled, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + }) { + if (!isEnabled) { + final Color defaultColor; + if (!theme.useMaterial3) { + defaultColor = theme.colorScheme.onPrimary.withValues(alpha: 0.12); + } else if (year2023) { + defaultColor = theme.colorScheme.onSurface.withValues(alpha: 0.38); + } else { + defaultColor = theme.colorScheme.onInverseSurface; + } + return sliderTheme.disabledActiveTickMarkColor ?? defaultColor; + } + final Color defaultColor; + if (!theme.useMaterial3) { + defaultColor = theme.colorScheme.onPrimary.withValues(alpha: 0.54); + } else if (year2023) { + defaultColor = theme.colorScheme.onPrimary.withValues(alpha: 0.38); + } else { + defaultColor = theme.colorScheme.onPrimary; + } + return widget.inactiveColor ?? + sliderTheme.activeTickMarkColor ?? + defaultColor; + } + + Color _getInactiveTickMarkColor({ + required Slider widget, + required bool isEnabled, + required ThemeData theme, + required SliderThemeData sliderTheme, + required bool year2023, + }) { + if (!isEnabled) { + final Color defaultColor; + if (!theme.useMaterial3) { + defaultColor = theme.colorScheme.onSurface.withValues(alpha: 0.12); + } else if (year2023) { + defaultColor = theme.colorScheme.onSurface.withValues(alpha: 0.38); + } else { + defaultColor = theme.colorScheme.onSurface; + } + return sliderTheme.disabledInactiveTickMarkColor ?? defaultColor; + } + final Color defaultColor; + if (!theme.useMaterial3) { + defaultColor = theme.colorScheme.primary.withValues(alpha: 0.54); + } else if (year2023) { + defaultColor = theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.38); + } else { + defaultColor = theme.colorScheme.onSecondaryContainer; + } + return widget.activeColor ?? + sliderTheme.inactiveTickMarkColor ?? + defaultColor; + } + + // Walks up the ancestor chain to find the nearest opaque background color. + Color _findBackgroundColor(Element element, ThemeData theme) { + Color? result; + element.visitAncestorElements((ancestor) { + final w = ancestor.widget; + Color? c; + if (w is Material && w.type != MaterialType.transparency) { + c = w.color ?? + (w.type == MaterialType.card ? theme.cardColor : theme.canvasColor); + } else if (w is ColoredBox) { + c = w.color; + } else if (w is Container) { + final dec = w.decoration; + c = dec is BoxDecoration ? dec.color : w.color; + } else if (w is DecoratedBox) { + final dec = w.decoration; + c = dec is BoxDecoration ? dec.color : null; + } else if (w is Card) { + c = w.color ?? theme.cardColor; + } else if (w is Scaffold) { + c = w.backgroundColor ?? theme.scaffoldBackgroundColor; + } + if (c != null && c.a == 1.0) { + result = c; + return false; + } + return true; + }); + return result ?? theme.colorScheme.surface; + } +} + +/// Holds the resolved visual properties of a [Slider] widget and builds the +/// corresponding [SRShapeWireframe]s: an inactive track (full background), +/// an optional secondary active segment, an active segment, and a thumb on +/// top. Each piece is rendered as a pill (cornerRadius = shortestSide / 2). +@immutable +class SliderNode extends CaptureNode { + final int inactiveTrackWireframeId; + final int secondaryActiveTrackWireframeId; + final int activeTrackWireframeId; + final List tickMarkWireframeIds; + final int gapWireframeId; + final int stopIndicatorWireframeId; + final int thumbWireframeId; + final Rect inactiveTrackRect; + final Rect? secondaryActiveTrackRect; + final Rect activeTrackRect; + final List activeTickMarkRects; + final List inactiveTickMarkRects; + final Rect? gapRect; + final Rect? stopIndicatorRect; + final Rect thumbRect; + final Color inactiveColor; + final Color secondaryActiveColor; + final Color activeColor; + final Color activeTickMarkColor; + final Color inactiveTickMarkColor; + final Color? gapColor; + final Color thumbColor; + + const SliderNode( + super.attributes, { + required this.inactiveTrackWireframeId, + required this.secondaryActiveTrackWireframeId, + required this.activeTrackWireframeId, + required this.tickMarkWireframeIds, + required this.gapWireframeId, + required this.stopIndicatorWireframeId, + required this.thumbWireframeId, + required this.inactiveTrackRect, + required this.secondaryActiveTrackRect, + required this.activeTrackRect, + required this.activeTickMarkRects, + required this.inactiveTickMarkRects, + required this.gapRect, + required this.stopIndicatorRect, + required this.thumbRect, + required this.inactiveColor, + required this.secondaryActiveColor, + required this.activeColor, + required this.activeTickMarkColor, + required this.inactiveTickMarkColor, + required this.gapColor, + required this.thumbColor, + }); + + @override + List buildWireframes() { + final wireframes = [ + ShapeWireframeBuilder.shape( + id: inactiveTrackWireframeId, + rect: inactiveTrackRect, + color: inactiveColor, + ), + ]; + + if (secondaryActiveTrackRect != null) { + wireframes.add(ShapeWireframeBuilder.shape( + id: secondaryActiveTrackWireframeId, + rect: secondaryActiveTrackRect!, + color: secondaryActiveColor, + )); + } + + wireframes.add(ShapeWireframeBuilder.shape( + id: activeTrackWireframeId, + rect: activeTrackRect, + color: activeColor, + )); + + // Tick marks for discrete sliders. Drawn before the gap so the gap + // overpaints any tick near the thumb. Active ticks (over the active + // track) use activeTickMarkColor; inactive ticks use the inactive color. + int tickIdx = 0; + for (final rect in activeTickMarkRects) { + wireframes.add(ShapeWireframeBuilder.shape( + id: tickMarkWireframeIds[tickIdx], + rect: rect, + color: activeTickMarkColor, + )); + tickIdx++; + } + for (final rect in inactiveTickMarkRects) { + wireframes.add(ShapeWireframeBuilder.shape( + id: tickMarkWireframeIds[tickIdx], + rect: rect, + color: inactiveTickMarkColor, + )); + tickIdx++; + } + + // M3-2024 gap: overpaints the tracks around the thumb in the background + // color. Sharp corners (cornerRadius: 0) so the cut against the rounded + // track edges produces a clean band. + if (gapRect != null && gapColor != null) { + wireframes.add(ShapeWireframeBuilder.shape( + id: gapWireframeId, + rect: gapRect!, + color: gapColor!, + cornerRadius: 0, + )); + } + + if (stopIndicatorRect != null) { + wireframes.add(ShapeWireframeBuilder.shape( + id: stopIndicatorWireframeId, + rect: stopIndicatorRect!, + color: activeColor, + )); + } + + wireframes.add(ShapeWireframeBuilder.shape( + id: thumbWireframeId, + rect: thumbRect, + color: thumbColor, + )); + + return wireframes; + } +} + +/// Builds [SRShapeWireframe] instances from a [Rect] + [Color]. Shared across +/// slider recorders (material + cupertino) to avoid duplicated helpers. +class ShapeWireframeBuilder { + const ShapeWireframeBuilder._(); + + static SRShapeWireframe shape({ + required int id, + required Rect rect, + required Color color, + double? cornerRadius, + }) { + return SRShapeWireframe( + id: id, + x: rect.left.round(), + y: rect.top.round(), + width: rect.width.round(), + height: rect.height.round(), + shapeStyle: SRShapeStyle( + backgroundColor: color.toHexString(), + cornerRadius: cornerRadius ?? rect.shortestSide / 2, + ), + ); + } +} diff --git a/packages/datadog_session_replay/lib/src/capture/recorder.dart b/packages/datadog_session_replay/lib/src/capture/recorder.dart index cc13d7ce..ef634249 100644 --- a/packages/datadog_session_replay/lib/src/capture/recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/recorder.dart @@ -16,12 +16,14 @@ import 'capture_node.dart'; import 'element_recorders/container_recorder.dart'; import 'element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart'; import 'element_recorders/cupertino_widgets/cupertino_radio_recorder.dart'; +import 'element_recorders/cupertino_widgets/cupertino_slider_recorder.dart'; import 'element_recorders/cupertino_widgets/cupertino_switch_recorder.dart'; import 'element_recorders/custom_paint_recorder.dart'; import 'element_recorders/editable_text_recorder.dart'; import 'element_recorders/image_recorder.dart'; import 'element_recorders/material_widgets/checkbox_recorder.dart'; import 'element_recorders/material_widgets/radio_recorder.dart'; +import 'element_recorders/material_widgets/slider_recorder.dart'; import 'element_recorders/material_widgets/switch_recorder.dart'; import 'element_recorders/privacy_recorder.dart'; import 'element_recorders/text_recorder.dart'; @@ -178,6 +180,8 @@ class SessionReplayRecorder { CupertinoRadioRecorder(keyGenerator), SwitchRecorder(keyGenerator), CupertinoSwitchRecorder(keyGenerator), + SliderRecorder(keyGenerator), + CupertinoSliderRecorder(keyGenerator), ]); } diff --git a/packages/datadog_session_replay/test/capture/widgets/slider_recorder_test.dart b/packages/datadog_session_replay/test/capture/widgets/slider_recorder_test.dart new file mode 100644 index 00000000..05f28869 --- /dev/null +++ b/packages/datadog_session_replay/test/capture/widgets/slider_recorder_test.dart @@ -0,0 +1,441 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'package:datadog_common_test/datadog_common_test.dart'; +import 'package:datadog_session_replay/datadog_session_replay.dart'; +import 'package:datadog_session_replay/src/capture/capture_node.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/cupertino_widgets/cupertino_slider_recorder.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/material_widgets/slider_recorder.dart'; +import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/extensions.dart'; +import 'package:datadog_session_replay/src/rum_context.dart'; +import 'package:datadog_session_replay/src/sr_data_models.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../simple_test_capture.dart'; + +SimpleTestCapture captureSlider( + SessionReplayRecorder recorder, + Widget slider, { + ThemeData? theme, +}) { + return SimpleTestCapture( + key: Key('key'), + recorder: recorder, + child: MaterialApp( + theme: theme, + home: Scaffold( + body: Center(child: slider), + ), + ), + ); +} + +void main() { + late SessionReplayRecorder recorder; + late RUMContext context; + + setUp(() { + recorder = SessionReplayRecorder.withCustomRecorders( + [ + SliderRecorder(KeyGenerator()), + CupertinoSliderRecorder(KeyGenerator()), + ], + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + + registerFallbackValue( + CapturedViewAttributes(paintBounds: Rect.zero, scaleX: 1.0, scaleY: 1.0), + ); + + context = RUMContext( + applicationId: randomString(), + sessionId: randomString(), + ); + recorder.updateContext(context); + }); + + List wireframesOf(CaptureResult? capture) { + return capture!.viewTreeSnapshot.nodes.first.buildWireframes(); + } + + // Both recorders emit the inactive track first and the thumb last. Anything + // in between (secondary, ticks, gap, stop indicator) varies by configuration. + SRShapeWireframe inactiveTrackOf(CaptureResult? capture) => + wireframesOf(capture).first as SRShapeWireframe; + + SRShapeWireframe thumbOf(CaptureResult? capture) => + wireframesOf(capture).last as SRShapeWireframe; + + void metaTestWidgets( + String testDescription, + List setups, + void Function(CaptureResult?) checks, { + VoidCallback? beforeEach, + VoidCallback? afterEach, + }) { + for (final setup in setups) { + testWidgets(testDescription, (tester) async { + // Given + beforeEach?.call(); + final tree = setup(); + await tester.pumpWidget(tree); + + // When + final capture = await recorder.performCapture(); + + // Then + checks(capture); + afterEach?.call(); + }); + } + } + + group('wireframe count', () { + metaTestWidgets( + 'slider produces 3 wireframes (inactive + active + thumb)', + [ + () => captureSlider(recorder, Slider(value: 0.5, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 0.5, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + expect(wireframesOf(capture).length, 3); + }, + ); + + metaTestWidgets( + 'slider produces a single capture node', + [ + () => captureSlider(recorder, Slider(value: 0.5, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 0.5, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + expect(capture!.viewTreeSnapshot.nodes.length, 1); + }, + ); + + metaTestWidgets( + 'disabled slider (onChanged: null) still produces 3 wireframes', + [ + () => captureSlider(recorder, Slider(value: 0.5, onChanged: null)), + () => captureSlider( + recorder, CupertinoSlider(value: 0.5, onChanged: null)), + ], + (capture) { + expect(capture, isNotNull); + expect(wireframesOf(capture).length, 3); + }, + ); + + testWidgets( + 'material slider with secondaryTrackValue adds a secondary track wireframe', + (tester) async { + final tree = captureSlider( + recorder, + Slider(value: 0.3, secondaryTrackValue: 0.7, onChanged: (_) {}), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + expect(wireframesOf(capture).length, 4); + }); + + testWidgets( + 'material slider with divisions=N adds N+1 tick mark wireframes', + (tester) async { + final tree = captureSlider( + recorder, + Slider(value: 0.5, divisions: 4, onChanged: (_) {}), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + // 3 (inactive + active + thumb) + 5 ticks = 8 + expect(wireframesOf(capture).length, 8); + }); + + testWidgets('CupertinoSlider with divisions still produces 3 wireframes', + (tester) async { + // CupertinoSlider snaps the value to divisions but doesn't render + // visual tick marks (unlike Material). + final tree = captureSlider( + recorder, + CupertinoSlider(value: 0.5, divisions: 4, onChanged: (_) {}), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + expect(wireframesOf(capture).length, 3); + }); + + testWidgets( + 'M3-2024 material slider (year2023: false) adds gap + stop indicator wireframes', + (tester) async { + final tree = captureSlider( + recorder, + Slider( + // ignore: deprecated_member_use + year2023: false, + value: 0.5, + onChanged: (_) {}, + ), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + // 3 (inactive + active + thumb) + 2 (gap + stop indicator) = 5 + expect(wireframesOf(capture).length, 5); + }); + }); + + group('colors', () { + metaTestWidgets( + 'thumb uses widget.thumbColor when set', + [ + () => captureSlider(recorder, + Slider(value: 0.5, onChanged: (_) {}, thumbColor: Colors.red)), + () => captureSlider( + recorder, + CupertinoSlider( + value: 0.5, onChanged: (_) {}, thumbColor: Colors.red)), + ], + (capture) { + expect(capture, isNotNull); + expect(thumbOf(capture).shapeStyle!.backgroundColor, + Colors.red.toHexString()); + }, + ); + + metaTestWidgets( + 'active track uses widget.activeColor when set', + [ + () => captureSlider(recorder, + Slider(value: 0.5, onChanged: (_) {}, activeColor: Colors.green)), + () => captureSlider( + recorder, + CupertinoSlider( + value: 0.5, onChanged: (_) {}, activeColor: Colors.green)), + ], + (capture) { + expect(capture, isNotNull); + // For both recorders, with no secondary/ticks/gap, the active track + // lives at index 1 (between [0] inactive and [last] thumb). + final active = wireframesOf(capture)[1] as SRShapeWireframe; + expect(active.shapeStyle!.backgroundColor, Colors.green.toHexString()); + }, + ); + + testWidgets('material inactive track uses widget.inactiveColor when set', + (tester) async { + final tree = captureSlider( + recorder, + Slider(value: 0.5, onChanged: (_) {}, inactiveColor: Colors.yellow), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + expect(inactiveTrackOf(capture).shapeStyle!.backgroundColor, + Colors.yellow.toHexString()); + }); + + testWidgets( + 'material secondary track uses widget.secondaryActiveColor when set', + (tester) async { + final tree = captureSlider( + recorder, + Slider( + value: 0.3, + secondaryTrackValue: 0.7, + onChanged: (_) {}, + secondaryActiveColor: Colors.purple, + ), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + // Order: [0] inactive, [1] secondary, [2] active, [3] thumb. + final secondary = wireframesOf(capture)[1] as SRShapeWireframe; + expect( + secondary.shapeStyle!.backgroundColor, Colors.purple.toHexString()); + }); + }); + + group('thumb position', () { + metaTestWidgets( + 'thumb at value=min sits on the left side of the track', + [ + () => captureSlider(recorder, Slider(value: 0.0, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 0.0, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + final track = inactiveTrackOf(capture); + final thumb = thumbOf(capture); + expect(thumb.x + thumb.width / 2, lessThan(track.x + track.width / 2)); + }, + ); + + metaTestWidgets( + 'thumb at value=max sits on the right side of the track', + [ + () => captureSlider(recorder, Slider(value: 1.0, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 1.0, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + final track = inactiveTrackOf(capture); + final thumb = thumbOf(capture); + expect( + thumb.x + thumb.width / 2, greaterThan(track.x + track.width / 2)); + }, + ); + + metaTestWidgets( + 'thumb at value=0.5 sits near the middle of the track', + [ + () => captureSlider(recorder, Slider(value: 0.5, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 0.5, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + final track = inactiveTrackOf(capture); + final thumb = thumbOf(capture); + final trackMid = track.x + track.width / 2; + final thumbMid = thumb.x + thumb.width / 2; + expect((thumbMid - trackMid).abs(), lessThan(2)); + }, + ); + + testWidgets( + 'material thumb position scales linearly with custom min/max range', + (tester) async { + final tree = captureSlider( + recorder, + Slider(value: 50.0, min: 0.0, max: 100.0, onChanged: (_) {}), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + final track = inactiveTrackOf(capture); + final thumb = thumbOf(capture); + final trackMid = track.x + track.width / 2; + final thumbMid = thumb.x + thumb.width / 2; + expect((thumbMid - trackMid).abs(), lessThan(2)); + }); + }); + + group('material year2023', () { + testWidgets( + 'year2023: false produces a handle-style thumb (taller than wide)', + (tester) async { + final tree = captureSlider( + recorder, + Slider( + // ignore: deprecated_member_use + year2023: false, + value: 0.5, + onChanged: (_) {}, + ), + ); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + final thumb = thumbOf(capture); + expect(thumb.height, 44); + expect(thumb.width, greaterThanOrEqualTo(2.0)); + expect(thumb.width, lessThanOrEqualTo(4.0)); + }); + + testWidgets('year2023: true produces a round thumb (square)', + (tester) async { + final tree = + captureSlider(recorder, Slider(value: 0.5, onChanged: (_) {})); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + final thumb = thumbOf(capture); + expect(thumb.width, thumb.height); + }); + }); + + group('cupertino specifics', () { + testWidgets('CupertinoSlider thumb is a circle (square wireframe)', + (tester) async { + final tree = captureSlider( + recorder, CupertinoSlider(value: 0.5, onChanged: (_) {})); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + final thumb = thumbOf(capture); + expect(thumb.width, thumb.height); + }); + + testWidgets( + 'CupertinoSlider default thumb color is white when no thumbColor is set', + (tester) async { + final tree = captureSlider( + recorder, CupertinoSlider(value: 0.5, onChanged: (_) {})); + await tester.pumpWidget(tree); + final capture = await recorder.performCapture(); + expect(capture, isNotNull); + expect(thumbOf(capture).shapeStyle!.backgroundColor, + CupertinoColors.white.toHexString()); + }); + }); + + group('privacy', () { + metaTestWidgets( + 'maskAllInputs anchors the thumb at midpoint regardless of value', + [ + () => captureSlider(recorder, Slider(value: 0.95, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 0.95, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + final track = inactiveTrackOf(capture); + final thumb = thumbOf(capture); + final trackMid = track.x + track.width / 2; + final thumbMid = thumb.x + thumb.width / 2; + expect((thumbMid - trackMid).abs(), lessThan(2)); + }, + beforeEach: () { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAllInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + ); + }, + ); + + metaTestWidgets( + 'maskSensitiveInputs does not anchor the thumb (default)', + [ + () => captureSlider(recorder, Slider(value: 1.0, onChanged: (_) {})), + () => captureSlider( + recorder, CupertinoSlider(value: 1.0, onChanged: (_) {})), + ], + (capture) { + expect(capture, isNotNull); + final track = inactiveTrackOf(capture); + final thumb = thumbOf(capture); + expect( + thumb.x + thumb.width / 2, greaterThan(track.x + track.width / 2)); + }, + ); + }); +}