Repository: ubuntu/yaru.dart

Issue:
Add YaruSwitch




Fix patch:
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 27278919a..992de664c 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -39,7 +39,7 @@ class _HomeState extends State<Home> {
         children: [
           YaruTile(
             title: const Text('Compact mode'),
-            trailing: Switch(
+            trailing: YaruSwitch(
               value: _compactMode,
               onChanged: (v) => setState(() => _compactMode = v),
             ),
diff --git a/example/lib/pages/controls_page.dart b/example/lib/pages/controls_page.dart
index 7c3916460..138b9a439 100644
--- a/example/lib/pages/controls_page.dart
+++ b/example/lib/pages/controls_page.dart
@@ -11,6 +11,7 @@ class ControlsPage extends StatefulWidget {
 class _ControlsPageState extends State<ControlsPage> {
   final List<bool?> _checkboxValues = [false, null, true];
   int? _radioValue = 1;
+  final List<bool> _switchValues = List.generate(3, (i) => i % 2 == 0);
 
   @override
   Widget build(BuildContext context) {
@@ -55,6 +56,22 @@ class _ControlsPageState extends State<ControlsPage> {
           ),
           const SizedBox(height: 10),
         ],
+        for (var i = 0; i < _switchValues.length; ++i) ...[
+          Row(
+            children: [
+              YaruSwitch(
+                value: _switchValues[i],
+                onChanged: (v) => setState(() => _switchValues[i] = v),
+              ),
+              const SizedBox(width: 10),
+              Switch(
+                value: _switchValues[i],
+                onChanged: null,
+              )
+            ],
+          ),
+          const SizedBox(height: 10),
+        ],
       ],
     );
   }
diff --git a/example/lib/pages/dialog_page.dart b/example/lib/pages/dialog_page.dart
index 1fadbed4d..255444ffc 100644
--- a/example/lib/pages/dialog_page.dart
+++ b/example/lib/pages/dialog_page.dart
@@ -77,7 +77,7 @@ class _DialogPageState extends State<DialogPage> {
             ),
             YaruTile(
               title: const Text('isCloseable'),
-              trailing: Switch(
+              trailing: YaruSwitch(
                 value: isCloseable,
                 onChanged: (value) => setState(() => isCloseable = value),
               ),
diff --git a/lib/src/constants.dart b/lib/src/constants.dart
index 4ce6aeb62..371238ed4 100644
--- a/lib/src/constants.dart
+++ b/lib/src/constants.dart
@@ -1,3 +1,5 @@
+import 'package:flutter/material.dart';
+
 /// The recommended padding around page content in Yaru applications.
 const kYaruPagePadding = 20.0;
 
@@ -16,4 +18,8 @@ const kYaruMasterDetailBreakpoint = 620.0;
 
 // Used by tooglable widgets to resize the canvas on active state
 // Need to be an even number
-const kCheckRadioActiveResizeFactor = 2;
+const kTogglableActiveResizeFactor = 2;
+
+const kCheckradioActivableAreaPadding = EdgeInsets.all(6);
+
+const kCheckradioTogglableSize = Size.square(20);
diff --git a/lib/src/controls/yaru_checkbox.dart b/lib/src/controls/yaru_checkbox.dart
index 914690418..0c1554940 100644
--- a/lib/src/controls/yaru_checkbox.dart
+++ b/lib/src/controls/yaru_checkbox.dart
@@ -12,7 +12,7 @@ class YaruCheckbox extends YaruTogglable<bool?> {
     super.key,
     required super.value,
     super.tristate,
-    super.onChanged,
+    required super.onChanged,
     super.focusNode,
     super.autofocus,
   }) : assert(tristate || value != null);
@@ -28,11 +28,14 @@ class YaruCheckbox extends YaruTogglable<bool?> {
 
 class _YaruCheckboxState extends YaruTogglableState<YaruCheckbox> {
   @override
-  bool get interactive => widget.onChanged != null;
+  EdgeInsets get activableAreaPadding => kCheckradioActivableAreaPadding;
+
+  @override
+  Size get togglableSize => kCheckradioTogglableSize;
 
   @override
   void handleTap([Intent? _]) {
-    if (!interactive) {
+    if (!widget.interactive) {
       return;
     }
     switch (widget.value) {
@@ -56,32 +59,27 @@ class _YaruCheckboxState extends YaruTogglableState<YaruCheckbox> {
 
 class _YaruCheckboxPainter extends YaruTogglablePainter {
   @override
-  void paint(Canvas canvas, Size size) {
-    final canvasSize = size;
-    final t = position.value;
-    final drawingOrigin = Offset(
-      kCheckRadioActiveResizeFactor / 2 * sizePosition.value,
-      kCheckRadioActiveResizeFactor / 2 * sizePosition.value,
-    );
-    final drawingSize = Size(
-      canvasSize.width - kCheckRadioActiveResizeFactor * sizePosition.value,
-      canvasSize.height - kCheckRadioActiveResizeFactor * sizePosition.value,
-    );
-
-    drawStateIndicator(canvas, canvasSize);
+  void paintTogglable(
+    Canvas canvas,
+    Size realSize,
+    Size size,
+    Offset origin,
+    double t,
+  ) {
+    drawStateIndicator(canvas, realSize, null);
     _drawBox(
       canvas,
-      drawingSize,
-      drawingOrigin,
+      size,
+      origin,
       oldChecked == false || checked == false ? t : 1,
     );
 
     // Four cases: false to null, false to true, null to false, true to false
     if (oldChecked == false || checked == false) {
       if (oldChecked == true || checked == true) {
-        _drawCheckMark(canvas, drawingSize, drawingOrigin, t);
+        _drawCheckMark(canvas, size, origin, t);
       } else if (oldChecked == null || checked == null) {
-        _drawDash(canvas, drawingSize, drawingOrigin, t);
+        _drawDash(canvas, size, origin, t);
       }
     }
     // Two cases: null to true, true to null
@@ -89,16 +87,16 @@ class _YaruCheckboxPainter extends YaruTogglablePainter {
       if (t <= 0.5) {
         final tShrink = 1 - t * 2;
         if (oldChecked == true) {
-          _drawCheckMark(canvas, drawingSize, drawingOrigin, tShrink);
+          _drawCheckMark(canvas, size, origin, tShrink);
         } else {
-          _drawDash(canvas, drawingSize, drawingOrigin, tShrink);
+          _drawDash(canvas, size, origin, tShrink);
         }
       } else {
         final tExpand = (t - 0.5) * 2.0;
         if (checked == true) {
-          _drawCheckMark(canvas, drawingSize, drawingOrigin, tExpand);
+          _drawCheckMark(canvas, size, origin, tExpand);
         } else {
-          _drawDash(canvas, drawingSize, drawingOrigin, tExpand);
+          _drawDash(canvas, size, origin, tExpand);
         }
       }
     }
diff --git a/lib/src/controls/yaru_radio.dart b/lib/src/controls/yaru_radio.dart
index 348ccb8ae..97ee7a015 100644
--- a/lib/src/controls/yaru_radio.dart
+++ b/lib/src/controls/yaru_radio.dart
@@ -5,7 +5,7 @@ import 'yaru_togglable.dart';
 
 const _kDotSizeFactor = 0.4;
 
-class YaruRadio<T> extends YaruTogglable<T> {
+class YaruRadio<T> extends YaruTogglable<T?> {
   const YaruRadio({
     super.key,
     required super.value,
@@ -24,15 +24,21 @@ class YaruRadio<T> extends YaruTogglable<T> {
   final T? groupValue;
 
   @override
-  YaruTogglableState<YaruRadio<T>> createState() {
-    return _YaruRadioState<T>();
+  YaruTogglableState<YaruRadio<T?>> createState() {
+    return _YaruRadioState<T?>();
   }
 }
 
-class _YaruRadioState<T> extends YaruTogglableState<YaruRadio<T>> {
+class _YaruRadioState<T> extends YaruTogglableState<YaruRadio<T?>> {
+  @override
+  EdgeInsets get activableAreaPadding => kCheckradioActivableAreaPadding;
+
+  @override
+  Size get togglableSize => kCheckradioTogglableSize;
+
   @override
   void handleTap([Intent? _]) {
-    if (!interactive) {
+    if (!widget.interactive) {
       return;
     }
 
@@ -51,21 +57,16 @@ class _YaruRadioState<T> extends YaruTogglableState<YaruRadio<T>> {
 
 class _YaruRadioPainter extends YaruTogglablePainter {
   @override
-  void paint(Canvas canvas, Size size) {
-    final canvasSize = size;
-    final t = position.value;
-    final drawingOrigin = Offset(
-      kCheckRadioActiveResizeFactor / 2 * sizePosition.value,
-      kCheckRadioActiveResizeFactor / 2 * sizePosition.value,
-    );
-    final drawingSize = Size(
-      canvasSize.width - kCheckRadioActiveResizeFactor * sizePosition.value,
-      canvasSize.height - kCheckRadioActiveResizeFactor * sizePosition.value,
-    );
-
-    drawStateIndicator(canvas, canvasSize);
-    _drawBox(canvas, drawingSize, drawingOrigin, t);
-    _drawDot(canvas, drawingSize, drawingOrigin, t);
+  void paintTogglable(
+    Canvas canvas,
+    Size realSize,
+    Size size,
+    Offset origin,
+    double t,
+  ) {
+    drawStateIndicator(canvas, realSize, null);
+    _drawBox(canvas, size, origin, t);
+    _drawDot(canvas, size, origin, t);
   }
 
   void _drawBox(Canvas canvas, Size size, Offset origin, double t) {
diff --git a/lib/src/controls/yaru_switch.dart b/lib/src/controls/yaru_switch.dart
new file mode 100644
index 000000000..ec584bf4c
--- /dev/null
+++ b/lib/src/controls/yaru_switch.dart
@@ -0,0 +1,99 @@
+import 'package:flutter/material.dart';
+
+import 'yaru_togglable.dart';
+
+const _kSwitchActivableAreaPadding =
+    EdgeInsets.symmetric(horizontal: 2, vertical: 5);
+const _kSwitchSize = Size(55, 30);
+const _kSwitchDotSizeFactor = 0.8;
+
+class YaruSwitch extends YaruTogglable<bool> {
+  const YaruSwitch({
+    super.key,
+    required super.value,
+    required super.onChanged,
+    super.focusNode,
+    super.autofocus,
+  });
+
+  @override
+  bool get checked => value;
+
+  @override
+  bool get tristate => false;
+
+  @override
+  YaruTogglableState<YaruSwitch> createState() {
+    return _YaruSwitchState();
+  }
+}
+
+class _YaruSwitchState extends YaruTogglableState<YaruSwitch> {
+  @override
+  EdgeInsets get activableAreaPadding => _kSwitchActivableAreaPadding;
+
+  @override
+  Size get togglableSize => _kSwitchSize;
+
+  @override
+  void handleTap([Intent? _]) {
+    if (!widget.interactive) {
+      return;
+    }
+
+    widget.onChanged!(!widget.value);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return buildToggleable(_YaruSwitchPainter());
+  }
+}
+
+class _YaruSwitchPainter extends YaruTogglablePainter {
+  @override
+  void paintTogglable(
+    Canvas canvas,
+    Size realSize,
+    Size size,
+    Offset origin,
+    double t,
+  ) {
+    _drawBox(canvas, size, origin, t);
+    _drawDot(canvas, size, origin, t);
+  }
+
+  void _drawBox(Canvas canvas, Size size, Offset origin, double t) {
+    canvas.drawRRect(
+      RRect.fromRectAndRadius(
+        Rect.fromLTWH(origin.dx, origin.dy, size.width, size.height),
+        Radius.circular(size.height),
+      ),
+      Paint()
+        ..color = interactive
+            ? Color.lerp(uncheckedColor, checkedColor, t)!
+            : Color.lerp(uncheckedDisabledColor, checkedDisabledColor, t)!
+        ..style = PaintingStyle.fill,
+    );
+  }
+
+  void _drawDot(Canvas canvas, Size size, Offset origin, double t) {
+    final margin = (size.height - size.height * _kSwitchDotSizeFactor) / 2;
+    final innerSize = Size(
+      size.width - margin * 2,
+      size.height - margin * 2,
+    );
+    final radius = innerSize.height / 2;
+
+    final start = Offset(radius + margin, radius + margin);
+    final end = Offset(innerSize.width + margin - radius, radius + margin);
+    final center = Offset.lerp(start, end, t)! + origin;
+
+    final paint = Paint()
+      ..color = interactive ? checkmarkColor : checkmarkDisabledColor
+      ..style = PaintingStyle.fill;
+
+    drawStateIndicator(canvas, size, center);
+    canvas.drawCircle(center, radius, paint);
+  }
+}
diff --git a/lib/src/controls/yaru_switch_button.dart b/lib/src/controls/yaru_switch_button.dart
index 89588a6d2..4ced7ed8a 100644
--- a/lib/src/controls/yaru_switch_button.dart
+++ b/lib/src/controls/yaru_switch_button.dart
@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 
+import 'yaru_switch.dart';
 import 'yaru_toggle_button.dart';
 
 /// A desktop style switch button with an interactive label.
@@ -43,17 +44,11 @@ class YaruSwitchButton extends StatelessWidget {
       title: title,
       subtitle: subtitle,
       contentPadding: contentPadding,
-      leading: SizedBox(
-        width: kMinInteractiveDimension + 8,
-        height: kMinInteractiveDimension - 8,
-        child: Center(
-          child: Switch(
-            value: value,
-            onChanged: onChanged,
-            focusNode: focusNode,
-            autofocus: autofocus,
-          ),
-        ),
+      leading: YaruSwitch(
+        value: value,
+        onChanged: onChanged,
+        focusNode: focusNode,
+        autofocus: autofocus,
       ),
       onToggled: onChanged != null ? () => onChanged!(!value) : null,
     );
diff --git a/lib/src/controls/yaru_togglable.dart b/lib/src/controls/yaru_togglable.dart
index b74b22d4b..9fed1ffcf 100644
--- a/lib/src/controls/yaru_togglable.dart
+++ b/lib/src/controls/yaru_togglable.dart
@@ -1,7 +1,7 @@
 import 'package:flutter/material.dart';
 
-const _kActivableAreaPadding = EdgeInsets.all(6);
-const _kTogglableSize = Size.square(20);
+import '../constants.dart';
+
 const _kTogglableAnimationDuration = Duration(milliseconds: 150);
 const _kTogglableSizeAnimationDuration = Duration(milliseconds: 100);
 const _kIndicatorAnimationDuration = Duration(milliseconds: 200);
@@ -12,7 +12,7 @@ abstract class YaruTogglable<T> extends StatefulWidget {
     super.key,
     required this.value,
     this.tristate = false,
-    this.onChanged,
+    required this.onChanged,
     this.focusNode,
     this.autofocus = false,
   });
@@ -23,7 +23,7 @@ abstract class YaruTogglable<T> extends StatefulWidget {
 
   bool? get checked;
 
-  final ValueChanged<T?>? onChanged;
+  final ValueChanged<T>? onChanged;
 
   bool get interactive => onChanged != null;
 
@@ -39,8 +39,6 @@ abstract class YaruTogglableState<S extends YaruTogglable> extends State<S>
   bool active = false;
   bool? oldChecked;
 
-  bool get interactive => widget.interactive;
-
   late CurvedAnimation _position;
   late AnimationController _positionController;
 
@@ -54,6 +52,9 @@ abstract class YaruTogglableState<S extends YaruTogglable> extends State<S>
     ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: handleTap),
   };
 
+  EdgeInsets get activableAreaPadding;
+  Size get togglableSize;
+
   @override
   void initState() {
     super.initState();
@@ -177,7 +178,7 @@ abstract class YaruTogglableState<S extends YaruTogglable> extends State<S>
   Widget _buildSemantics({required Widget child}) {
     return Semantics(
       checked: widget.checked ?? false,
-      enabled: interactive,
+      enabled: widget.interactive,
       child: child,
     );
   }
@@ -185,16 +186,17 @@ abstract class YaruTogglableState<S extends YaruTogglable> extends State<S>
   Widget _buildEventDetectors({required Widget child}) {
     return FocusableActionDetector(
       actions: _actionMap,
-      enabled: interactive,
+      enabled: widget.interactive,
       focusNode: widget.focusNode,
       autofocus: widget.autofocus,
       onShowFocusHighlight: _handleFocusChange,
       onShowHoverHighlight: _handleHoverChange,
-      mouseCursor:
-          interactive ? SystemMouseCursors.click : SystemMouseCursors.basic,
+      mouseCursor: widget.interactive
+          ? SystemMouseCursors.click
+          : SystemMouseCursors.basic,
       child: GestureDetector(
-        excludeFromSemantics: !interactive,
-        onTapDown: (_) => _handleActiveChange(interactive),
+        excludeFromSemantics: !widget.interactive,
+        onTapDown: (_) => _handleActiveChange(widget.interactive),
         onTap: handleTap,
         onTapUp: (_) => _handleActiveChange(false),
         onTapCancel: () => _handleActiveChange(false),
@@ -224,28 +226,32 @@ abstract class YaruTogglableState<S extends YaruTogglable> extends State<S>
 
     return _buildSemantics(
       child: _buildEventDetectors(
-        child: Padding(
-          padding: _kActivableAreaPadding,
-          child: CustomPaint(
-            size: _kTogglableSize,
-            painter: painter
-              ..interactive = interactive
-              ..hover = hover
-              ..focus = focus
-              ..active = active
-              ..checked = widget.checked
-              ..oldChecked = oldChecked
-              ..position = _position
-              ..sizePosition = _sizePosition
-              ..indicatorPosition = _indicatorPosition
-              ..uncheckedColor = uncheckedColor
-              ..checkedColor = checkedColor
-              ..checkmarkColor = checkmarkColor
-              ..uncheckedDisabledColor = uncheckedDisabledColor
-              ..checkedDisabledColor = checkedDisabledColor
-              ..checkmarkDisabledColor = checkmarkDisabledColor
-              ..hoverIndicatorColor = hoverIndicatorColor
-              ..focusIndicatorColor = focusIndicatorColor,
+        child: SizedBox(
+          width: togglableSize.width + activableAreaPadding.horizontal,
+          height: togglableSize.height + activableAreaPadding.vertical,
+          child: Padding(
+            padding: activableAreaPadding,
+            child: CustomPaint(
+              size: togglableSize,
+              painter: painter
+                ..interactive = widget.interactive
+                ..hover = hover
+                ..focus = focus
+                ..active = active
+                ..checked = widget.checked
+                ..oldChecked = oldChecked
+                ..position = _position
+                ..sizePosition = _sizePosition
+                ..indicatorPosition = _indicatorPosition
+                ..uncheckedColor = uncheckedColor
+                ..checkedColor = checkedColor
+                ..checkmarkColor = checkmarkColor
+                ..uncheckedDisabledColor = uncheckedDisabledColor
+                ..checkedDisabledColor = checkedDisabledColor
+                ..checkmarkDisabledColor = checkmarkDisabledColor
+                ..hoverIndicatorColor = hoverIndicatorColor
+                ..focusIndicatorColor = focusIndicatorColor,
+            ),
           ),
         ),
       ),
@@ -307,21 +313,43 @@ abstract class YaruTogglablePainter extends ChangeNotifier
     notifyListeners();
   }
 
-  void drawStateIndicator(Canvas canvas, Size canvasSize) {
+  void drawStateIndicator(Canvas canvas, Size canvasSize, Offset? offset) {
     if (interactive) {
+      final defaultOffset = Offset(canvasSize.width / 2, canvasSize.height / 2);
       final color = focus ? focusIndicatorColor : hoverIndicatorColor;
+      final paint = Paint()
+        ..color =
+            Color.lerp(Colors.transparent, color, indicatorPosition.value)!
+        ..style = PaintingStyle.fill;
 
-      canvas.drawCircle(
-        Offset(canvasSize.width / 2, canvasSize.height / 2),
-        _kIndicatorRadius,
-        Paint()
-          ..color =
-              Color.lerp(Colors.transparent, color, indicatorPosition.value)!
-          ..style = PaintingStyle.fill,
-      );
+      canvas.drawCircle(offset ?? defaultOffset, _kIndicatorRadius, paint);
     }
   }
 
+  @override
+  void paint(Canvas canvas, Size size) {
+    final canvasSize = size;
+    final t = position.value;
+    final drawingOrigin = Offset(
+      kTogglableActiveResizeFactor / 2 * sizePosition.value,
+      kTogglableActiveResizeFactor / 2 * sizePosition.value,
+    );
+    final drawingSize = Size(
+      canvasSize.width - kTogglableActiveResizeFactor * sizePosition.value,
+      canvasSize.height - kTogglableActiveResizeFactor * sizePosition.value,
+    );
+
+    paintTogglable(canvas, size, drawingSize, drawingOrigin, t);
+  }
+
+  void paintTogglable(
+    Canvas canvas,
+    Size realSize,
+    Size size,
+    Offset origin,
+    double t,
+  );
+
   @override
   bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
 
diff --git a/lib/yaru_widgets.dart b/lib/yaru_widgets.dart
index 14027d1b3..1839440e5 100644
--- a/lib/yaru_widgets.dart
+++ b/lib/yaru_widgets.dart
@@ -14,6 +14,7 @@ export 'src/controls/yaru_popup_menu_button.dart';
 export 'src/controls/yaru_progress_indicator.dart';
 export 'src/controls/yaru_radio.dart';
 export 'src/controls/yaru_radio_button.dart';
+export 'src/controls/yaru_switch.dart';
 export 'src/controls/yaru_switch_button.dart';
 export 'src/controls/yaru_title_bar.dart';
 export 'src/controls/yaru_toggle_button.dart';


Test patch:
diff --git a/test/controls/goldens/yaru_switch-checked-dark.png b/test/controls/goldens/yaru_switch-checked-dark.png
new file mode 100644
index 000000000..7be922868
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-disabled-dark.png b/test/controls/goldens/yaru_switch-checked-disabled-dark.png
new file mode 100644
index 000000000..f01050b5c
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-disabled-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-disabled-light.png b/test/controls/goldens/yaru_switch-checked-disabled-light.png
new file mode 100644
index 000000000..506462ee5
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-disabled-light.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-focused-dark.png b/test/controls/goldens/yaru_switch-checked-focused-dark.png
new file mode 100644
index 000000000..30bc2d057
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-focused-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-focused-light.png b/test/controls/goldens/yaru_switch-checked-focused-light.png
new file mode 100644
index 000000000..1b0a8a16a
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-focused-light.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-hovered-dark.png b/test/controls/goldens/yaru_switch-checked-hovered-dark.png
new file mode 100644
index 000000000..7b9d099d7
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-hovered-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-hovered-light.png b/test/controls/goldens/yaru_switch-checked-hovered-light.png
new file mode 100644
index 000000000..513f79059
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-hovered-light.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-light.png b/test/controls/goldens/yaru_switch-checked-light.png
new file mode 100644
index 000000000..dd0b40a95
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-light.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-pressed-dark.png b/test/controls/goldens/yaru_switch-checked-pressed-dark.png
new file mode 100644
index 000000000..3636cc50f
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-pressed-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-checked-pressed-light.png b/test/controls/goldens/yaru_switch-checked-pressed-light.png
new file mode 100644
index 000000000..1bb9c91fc
Binary files /dev/null and b/test/controls/goldens/yaru_switch-checked-pressed-light.png differ
diff --git a/test/controls/goldens/yaru_switch-unchecked-dark.png b/test/controls/goldens/yaru_switch-unchecked-dark.png
new file mode 100644
index 000000000..f165846b6
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unchecked-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-unchecked-light.png b/test/controls/goldens/yaru_switch-unchecked-light.png
new file mode 100644
index 000000000..c238035dd
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unchecked-light.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-disabled-dark.png b/test/controls/goldens/yaru_switch-unckecked-disabled-dark.png
new file mode 100644
index 000000000..a019875b8
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-disabled-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-disabled-light.png b/test/controls/goldens/yaru_switch-unckecked-disabled-light.png
new file mode 100644
index 000000000..01ef59f1b
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-disabled-light.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-focused-dark.png b/test/controls/goldens/yaru_switch-unckecked-focused-dark.png
new file mode 100644
index 000000000..5752dd8c0
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-focused-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-focused-light.png b/test/controls/goldens/yaru_switch-unckecked-focused-light.png
new file mode 100644
index 000000000..3efab7fa8
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-focused-light.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-hovered-dark.png b/test/controls/goldens/yaru_switch-unckecked-hovered-dark.png
new file mode 100644
index 000000000..90100bbf8
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-hovered-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-hovered-light.png b/test/controls/goldens/yaru_switch-unckecked-hovered-light.png
new file mode 100644
index 000000000..9d4f998a5
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-hovered-light.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-pressed-dark.png b/test/controls/goldens/yaru_switch-unckecked-pressed-dark.png
new file mode 100644
index 000000000..cca1d99a4
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-pressed-dark.png differ
diff --git a/test/controls/goldens/yaru_switch-unckecked-pressed-light.png b/test/controls/goldens/yaru_switch-unckecked-pressed-light.png
new file mode 100644
index 000000000..f17f60300
Binary files /dev/null and b/test/controls/goldens/yaru_switch-unckecked-pressed-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-dark.png b/test/controls/goldens/yaru_switch_button-off-dark.png
index 82df4c1ac..49fec3b0c 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-dark.png and b/test/controls/goldens/yaru_switch_button-off-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-disabled-dark.png b/test/controls/goldens/yaru_switch_button-off-disabled-dark.png
index cf867e4b3..f3dd2dc35 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-disabled-dark.png and b/test/controls/goldens/yaru_switch_button-off-disabled-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-disabled-light.png b/test/controls/goldens/yaru_switch_button-off-disabled-light.png
index 0e3586aa2..eb36a3440 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-disabled-light.png and b/test/controls/goldens/yaru_switch_button-off-disabled-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-focused-dark.png b/test/controls/goldens/yaru_switch_button-off-focused-dark.png
index 2353f499b..678cb1367 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-focused-dark.png and b/test/controls/goldens/yaru_switch_button-off-focused-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-focused-light.png b/test/controls/goldens/yaru_switch_button-off-focused-light.png
index 19ad410ea..24c3925b6 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-focused-light.png and b/test/controls/goldens/yaru_switch_button-off-focused-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-hovered-dark.png b/test/controls/goldens/yaru_switch_button-off-hovered-dark.png
index 68f1225a1..e0742f64e 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-hovered-dark.png and b/test/controls/goldens/yaru_switch_button-off-hovered-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-hovered-light.png b/test/controls/goldens/yaru_switch_button-off-hovered-light.png
index 74dd10c99..72ff9179b 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-hovered-light.png and b/test/controls/goldens/yaru_switch_button-off-hovered-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-light.png b/test/controls/goldens/yaru_switch_button-off-light.png
index 77b1ec6d7..8d8a93dca 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-light.png and b/test/controls/goldens/yaru_switch_button-off-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-pressed-dark.png b/test/controls/goldens/yaru_switch_button-off-pressed-dark.png
index 68f1225a1..4b8f705e9 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-pressed-dark.png and b/test/controls/goldens/yaru_switch_button-off-pressed-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-off-pressed-light.png b/test/controls/goldens/yaru_switch_button-off-pressed-light.png
index 74dd10c99..d68a47364 100644
Binary files a/test/controls/goldens/yaru_switch_button-off-pressed-light.png and b/test/controls/goldens/yaru_switch_button-off-pressed-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-dark.png b/test/controls/goldens/yaru_switch_button-on-dark.png
index bdc34b662..f12b470d1 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-dark.png and b/test/controls/goldens/yaru_switch_button-on-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-disabled-dark.png b/test/controls/goldens/yaru_switch_button-on-disabled-dark.png
index 9f1e1ef15..00a41d9fd 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-disabled-dark.png and b/test/controls/goldens/yaru_switch_button-on-disabled-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-disabled-light.png b/test/controls/goldens/yaru_switch_button-on-disabled-light.png
index 753013a15..2540c48bd 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-disabled-light.png and b/test/controls/goldens/yaru_switch_button-on-disabled-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-focused-dark.png b/test/controls/goldens/yaru_switch_button-on-focused-dark.png
index 3c9ea48c2..fe29f9777 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-focused-dark.png and b/test/controls/goldens/yaru_switch_button-on-focused-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-focused-light.png b/test/controls/goldens/yaru_switch_button-on-focused-light.png
index 0eec04c2e..7a1241f6d 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-focused-light.png and b/test/controls/goldens/yaru_switch_button-on-focused-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-hovered-dark.png b/test/controls/goldens/yaru_switch_button-on-hovered-dark.png
index 679af8759..f0af79f1a 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-hovered-dark.png and b/test/controls/goldens/yaru_switch_button-on-hovered-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-hovered-light.png b/test/controls/goldens/yaru_switch_button-on-hovered-light.png
index 9b01b2011..16768d30f 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-hovered-light.png and b/test/controls/goldens/yaru_switch_button-on-hovered-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-light.png b/test/controls/goldens/yaru_switch_button-on-light.png
index 7a7833f52..9fae90be3 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-light.png and b/test/controls/goldens/yaru_switch_button-on-light.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-pressed-dark.png b/test/controls/goldens/yaru_switch_button-on-pressed-dark.png
index 679af8759..08342f8c2 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-pressed-dark.png and b/test/controls/goldens/yaru_switch_button-on-pressed-dark.png differ
diff --git a/test/controls/goldens/yaru_switch_button-on-pressed-light.png b/test/controls/goldens/yaru_switch_button-on-pressed-light.png
index 9b01b2011..b5c93f71d 100644
Binary files a/test/controls/goldens/yaru_switch_button-on-pressed-light.png and b/test/controls/goldens/yaru_switch_button-on-pressed-light.png differ
diff --git a/test/controls/yaru_switch_button_test.dart b/test/controls/yaru_switch_button_test.dart
index 4d80d3c8d..d66a94e6e 100644
--- a/test/controls/yaru_switch_button_test.dart
+++ b/test/controls/yaru_switch_button_test.dart
@@ -25,14 +25,14 @@ void main() {
         .pumpWidget(builder(title: const Text('title'), subtitle: null));
     expect(find.text('title'), findsOneWidget);
     expect(find.text('subtitle'), findsNothing);
-    expect(find.byType(Switch), findsOneWidget);
+    expect(find.byType(YaruSwitch), findsOneWidget);
 
     await tester.pumpWidget(
       builder(title: const Text('title'), subtitle: const Text('subtitle')),
     );
     expect(find.text('title'), findsOneWidget);
     expect(find.text('subtitle'), findsOneWidget);
-    expect(find.byType(Switch), findsOneWidget);
+    expect(find.byType(YaruSwitch), findsOneWidget);
   });
 
   testWidgets('the labels react to taps', (tester) async {
@@ -169,10 +169,10 @@ void main() {
       await tester.pumpAndSettle();
 
       if (variant.hasState(MaterialState.pressed)) {
-        await tester.down(find.byType(Switch));
+        await tester.down(find.byType(YaruSwitch));
         await tester.pumpAndSettle();
       } else if (variant.hasState(MaterialState.hovered)) {
-        await tester.hover(find.byType(Switch));
+        await tester.hover(find.byType(YaruSwitch));
         await tester.pumpAndSettle();
       }
 
diff --git a/test/controls/yaru_switch_test.dart b/test/controls/yaru_switch_test.dart
new file mode 100644
index 000000000..ea434ce0a
--- /dev/null
+++ b/test/controls/yaru_switch_test.dart
@@ -0,0 +1,147 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:yaru_widgets/yaru_widgets.dart';
+
+import '../yaru_golden_tester.dart';
+
+void main() {
+  testWidgets('the switch react to taps', (tester) async {
+    bool? changedValue;
+    Widget builder({required bool initialValue}) {
+      return MaterialApp(
+        home: Scaffold(
+          body: YaruSwitch(
+            value: initialValue,
+            onChanged: (v) => changedValue = v,
+          ),
+        ),
+      );
+    }
+
+    final switchFinder = find.byType(YaruSwitch);
+
+    await tester.pumpWidget(builder(initialValue: false));
+    await tester.tap(switchFinder);
+    expect(changedValue, isTrue);
+
+    await tester.pumpWidget(builder(initialValue: true));
+    await tester.tap(switchFinder);
+    expect(changedValue, isFalse);
+  });
+
+  testWidgets('mouse cursor changes depending on the state', (tester) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Column(
+            children: [
+              YaruSwitch(
+                value: false,
+                onChanged: (_) {},
+              ),
+              const YaruSwitch(
+                value: false,
+                onChanged: null,
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    final gesture =
+        await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
+    await gesture.addPointer(location: Offset.zero);
+    addTearDown(gesture.removePointer);
+
+    await gesture.moveTo(
+      tester.getCenter(
+        find.byWidgetPredicate(
+          (widget) => widget is YaruSwitch && widget.onChanged != null,
+        ),
+      ),
+    );
+    await tester.pump();
+    expect(
+      RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1),
+      SystemMouseCursors.click,
+    );
+
+    await gesture.moveTo(
+      tester.getCenter(
+        find.byWidgetPredicate(
+          (widget) => widget is YaruSwitch && widget.onChanged == null,
+        ),
+      ),
+    );
+    await tester.pump();
+    expect(
+      RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1),
+      SystemMouseCursors.basic,
+    );
+  });
+
+  testWidgets(
+    'golden images',
+    (tester) async {
+      final variant = goldenVariant.currentValue!;
+
+      // ensure traditional focus highlight
+      FocusManager.instance.highlightStrategy =
+          FocusHighlightStrategy.alwaysTraditional;
+
+      await tester.pumpScaffold(
+        YaruSwitch(
+          autofocus: variant.hasState(MaterialState.focused),
+          value: variant.hasState(MaterialState.selected),
+          onChanged: variant.hasState(MaterialState.disabled) ? null : (_) {},
+        ),
+        themeMode: variant.themeMode,
+        size: const Size(62, 37),
+      );
+      await tester.pumpAndSettle();
+
+      if (variant.hasState(MaterialState.pressed)) {
+        await tester.down(find.byType(YaruSwitch));
+        await tester.pumpAndSettle();
+      } else if (variant.hasState(MaterialState.hovered)) {
+        await tester.hover(find.byType(YaruSwitch));
+        await tester.pumpAndSettle();
+      }
+
+      await expectLater(
+        find.byType(YaruSwitch),
+        matchesGoldenFile('goldens/yaru_switch-${variant.label}.png'),
+      );
+    },
+    variant: goldenVariant,
+    tags: 'golden',
+  );
+}
+
+final goldenVariant = ValueVariant({
+  ...goldenThemeVariants('unchecked', <MaterialState>{}),
+  ...goldenThemeVariants('unckecked-disabled', {MaterialState.disabled}),
+  ...goldenThemeVariants('unckecked-focused', {MaterialState.focused}),
+  ...goldenThemeVariants('unckecked-hovered', {MaterialState.hovered}),
+  ...goldenThemeVariants('unckecked-pressed', {MaterialState.pressed}),
+  ...goldenThemeVariants('checked', {MaterialState.selected}),
+  ...goldenThemeVariants('checked-disabled', {
+    MaterialState.selected,
+    MaterialState.disabled,
+  }),
+  ...goldenThemeVariants('checked-focused', {
+    MaterialState.selected,
+    MaterialState.focused,
+  }),
+  ...goldenThemeVariants('checked-hovered', {
+    MaterialState.selected,
+    MaterialState.hovered,
+  }),
+  ...goldenThemeVariants('checked-pressed', {
+    MaterialState.selected,
+    MaterialState.pressed,
+  }),
+});