diff --git a/example/lib/example_page_items.dart b/example/lib/example_page_items.dart index 7f0b0e5a1..80d73a172 100644 --- a/example/lib/example_page_items.dart +++ b/example/lib/example_page_items.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:yaru_icons/yaru_icons.dart'; import 'pages/banner_page.dart'; import 'pages/carousel_page.dart'; +import 'pages/check_button_page.dart'; import 'pages/color_disk_page.dart'; import 'pages/draggable_page.dart'; import 'pages/expandable_page.dart'; import 'pages/icon_button_page.dart'; import 'pages/option_button_page.dart'; import 'pages/progress_indicator_page.dart'; +import 'pages/radio_button_page.dart'; import 'pages/section_page.dart'; import 'pages/selectable_container_page.dart'; import 'pages/tabbed_page_page.dart'; @@ -38,6 +40,12 @@ final examplePageItems = [ pageBuilder: (_) => const CarouselPage(), iconBuilder: (context, selected) => const Icon(YaruIcons.refresh), ), + PageItem( + titleBuilder: (context) => const Text('YaruCheckButton'), + pageBuilder: (context) => const CheckButtonPage(), + iconBuilder: (context, selected) => + const Icon(YaruIcons.checkbox_button_checked), + ), PageItem( titleBuilder: (context) => const Text('YaruColorDisk'), pageBuilder: (context) => const ColorDiskPage(), @@ -68,6 +76,12 @@ final examplePageItems = [ iconBuilder: (context, selected) => const Icon(YaruIcons.download), pageBuilder: (_) => const ProgressIndicatorPage(), ), + PageItem( + titleBuilder: (context) => const Text('YaruRadioButton'), + pageBuilder: (context) => const RadioButtonPage(), + iconBuilder: (context, selected) => + const Icon(YaruIcons.radio_button_checked), + ), PageItem( titleBuilder: (context) => const Text('YaruSection'), iconBuilder: (context, selected) => const Icon(YaruIcons.window), diff --git a/example/lib/pages/check_button_page.dart b/example/lib/pages/check_button_page.dart new file mode 100644 index 000000000..a5d17a58f --- /dev/null +++ b/example/lib/pages/check_button_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:yaru_widgets/yaru_widgets.dart'; + +class CheckButtonPage extends StatefulWidget { + const CheckButtonPage({super.key}); + + @override + _CheckButtonPageState createState() => _CheckButtonPageState(); +} + +class _CheckButtonPageState extends State { + final _values = List.generate(3, (i) => i % 2 == 0); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(kYaruPagePadding), + children: [ + for (var i = 0; i < _values.length; ++i) ...[ + YaruCheckButton( + value: _values[i], + onChanged: (v) => setState(() => _values[i] = v!), + title: const Text('YaruCheckButton'), + ), + const SizedBox(height: 10), + ], + ], + ); + } +} diff --git a/example/lib/pages/radio_button_page.dart b/example/lib/pages/radio_button_page.dart new file mode 100644 index 000000000..3d973f2e3 --- /dev/null +++ b/example/lib/pages/radio_button_page.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:yaru_widgets/yaru_widgets.dart'; + +class RadioButtonPage extends StatefulWidget { + const RadioButtonPage({super.key}); + + @override + _RadioButtonPageState createState() => _RadioButtonPageState(); +} + +class _RadioButtonPageState extends State { + int _value = 0; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(kYaruPagePadding), + children: [ + for (var i = 0; i < 3; ++i) ...[ + YaruRadioButton( + value: i, + groupValue: _value, + onChanged: (v) => setState(() => _value = v!), + title: const Text('YaruRadioButton'), + ), + const SizedBox(height: 10), + ], + ], + ); + } +} diff --git a/lib/src/controls/yaru_check_button.dart b/lib/src/controls/yaru_check_button.dart new file mode 100644 index 000000000..568c5df64 --- /dev/null +++ b/lib/src/controls/yaru_check_button.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'yaru_toggle_button.dart'; + +/// A desktop style check button with an interactive label. +class YaruCheckButton extends StatelessWidget { + /// Creates a new check button. + const YaruCheckButton({ + super.key, + required this.value, + required this.onChanged, + required this.title, + this.subtitle, + this.contentPadding, + }); + + /// See [Checkbox.value] + final bool value; + + /// See [Checkbox.onChanged] + final ValueChanged? onChanged; + + /// See [YaruToggleButton.title] + final Widget title; + + /// See [YaruToggleButton.subtitle] + final Widget? subtitle; + + /// See [YaruToggleButton.contentPadding] + final EdgeInsetsGeometry? contentPadding; + + @override + Widget build(BuildContext context) { + return YaruToggleButton( + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + leading: SizedBox.square( + dimension: kMinInteractiveDimension - 8, + child: Center( + child: Checkbox( + value: value, + onChanged: onChanged, + ), + ), + ), + onToggled: onChanged != null ? () => onChanged!(!value) : null, + ); + } +} diff --git a/lib/src/controls/yaru_radio_button.dart b/lib/src/controls/yaru_radio_button.dart new file mode 100644 index 000000000..d114eec7a --- /dev/null +++ b/lib/src/controls/yaru_radio_button.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'yaru_toggle_button.dart'; + +/// A desktop style radio button with an interactive label. +class YaruRadioButton extends StatelessWidget { + /// Creates a new radio button. + const YaruRadioButton({ + super.key, + required this.value, + required this.groupValue, + required this.onChanged, + required this.title, + this.subtitle, + this.contentPadding, + }); + + /// See [Radio.value] + final T value; + + /// See [Radio.groupValue] + final T? groupValue; + + /// See [Radio.onChanged] + final ValueChanged? onChanged; + + /// See [YaruToggleButton.title] + final Widget title; + + /// See [YaruToggleButton.subtitle] + final Widget? subtitle; + + /// See [YaruToggleButton.contentPadding] + final EdgeInsetsGeometry? contentPadding; + + @override + Widget build(BuildContext context) { + return YaruToggleButton( + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + leading: SizedBox.square( + dimension: kMinInteractiveDimension - 8, + child: Center( + child: Radio( + value: value, + groupValue: groupValue, + onChanged: onChanged, + ), + ), + ), + onToggled: onChanged != null ? () => onChanged!(value) : null, + ); + } +} diff --git a/lib/src/controls/yaru_toggle_button.dart b/lib/src/controls/yaru_toggle_button.dart new file mode 100644 index 000000000..f43eec3cc --- /dev/null +++ b/lib/src/controls/yaru_toggle_button.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'yaru_toggle_button_theme.dart'; + +part 'yaru_toggle_button_layout.dart'; + +/// A desktop style toggle button with an indicator and an interactive label. +/// +/// See [YaruCheckButton] and [YaruRadioButton] for concrete implementations. +class YaruToggleButton extends StatelessWidget { + /// Creates a toggle button. + const YaruToggleButton({ + super.key, + required this.leading, + required this.title, + this.subtitle, + this.contentPadding, + this.onToggled, + }); + + /// The toggle indicator. + final Widget leading; + + /// The button label. + final Widget title; + + /// An optional secondary label. + final Widget? subtitle; + + /// Padding around the content. + final EdgeInsetsGeometry? contentPadding; + + /// Called when the button is toggled. + final VoidCallback? onToggled; + + @override + Widget build(BuildContext context) { + final theme = YaruToggleButtonTheme.of(context); + return MergeSemantics( + child: Semantics( + child: GestureDetector( + onTap: onToggled, + child: MouseRegion( + cursor: onToggled != null + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: Padding( + padding: contentPadding ?? EdgeInsets.zero, + child: _YaryToggleButtonLayout( + horizontalSpacing: theme?.horizontalSpacing ?? 8, + verticalSpacing: theme?.verticalSpacing ?? 4, + textDirection: Directionality.of(context), + leading: leading, + title: _wrapTextStyle( + context, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.subtitle1!, + child: title, + ), + subtitle: subtitle != null + ? _wrapTextStyle( + context, + softWrap: true, + style: Theme.of(context).textTheme.caption!, + child: subtitle!, + ) + : null, + ), + ), + ), + ), + ), + ); + } + + Widget _wrapTextStyle( + BuildContext context, { + required Widget child, + required TextStyle style, + TextOverflow overflow = TextOverflow.clip, + bool softWrap = false, + }) { + final color = onToggled == null ? Theme.of(context).disabledColor : null; + return DefaultTextStyle( + style: style.copyWith(color: color), + overflow: overflow, + softWrap: softWrap, + child: child, + ); + } +} diff --git a/lib/src/controls/yaru_toggle_button_layout.dart b/lib/src/controls/yaru_toggle_button_layout.dart new file mode 100644 index 000000000..6607154b9 --- /dev/null +++ b/lib/src/controls/yaru_toggle_button_layout.dart @@ -0,0 +1,283 @@ +// Based on flutter/packages/flutter/lib/src/material/list_tile.dart +// +// Copyright 2014 The Flutter Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived +// from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +part of 'yaru_toggle_button.dart'; + +enum _YaruToggleButtonSlot { leading, title, subtitle } + +class _YaryToggleButtonLayout extends RenderObjectWidget + with SlottedMultiChildRenderObjectWidgetMixin<_YaruToggleButtonSlot> { + const _YaryToggleButtonLayout({ + required this.leading, + required this.title, + required this.subtitle, + required this.horizontalSpacing, + required this.verticalSpacing, + required this.textDirection, + }); + + final Widget leading; + final Widget title; + final Widget? subtitle; + final double horizontalSpacing; + final double verticalSpacing; + final TextDirection textDirection; + + @override + Iterable<_YaruToggleButtonSlot> get slots => _YaruToggleButtonSlot.values; + + @override + Widget? childForSlot(_YaruToggleButtonSlot slot) { + switch (slot) { + case _YaruToggleButtonSlot.leading: + return leading; + case _YaruToggleButtonSlot.title: + return title; + case _YaruToggleButtonSlot.subtitle: + return subtitle; + } + } + + @override + _YaruRenderToggleButton createRenderObject(BuildContext context) { + return _YaruRenderToggleButton( + horizontalSpacing: horizontalSpacing, + verticalSpacing: verticalSpacing, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _YaruRenderToggleButton renderObject, + ) { + renderObject.textDirection = textDirection; + } +} + +class _YaruRenderToggleButton extends RenderBox + with SlottedContainerRenderObjectMixin<_YaruToggleButtonSlot> { + _YaruRenderToggleButton({ + required double horizontalSpacing, + required double verticalSpacing, + required TextDirection textDirection, + }) : _horizontalSpacing = horizontalSpacing, + _verticalSpacing = verticalSpacing, + _textDirection = textDirection; + + RenderBox? get leading => childForSlot(_YaruToggleButtonSlot.leading); + RenderBox? get title => childForSlot(_YaruToggleButtonSlot.title); + RenderBox? get subtitle => childForSlot(_YaruToggleButtonSlot.subtitle); + + @override + Iterable get children { + return [ + if (leading != null) leading!, + if (title != null) title!, + if (subtitle != null) subtitle! + ]; + } + + double get horizontalSpacing => _horizontalSpacing; + double _horizontalSpacing; + set horizontalSpacing(double value) { + if (_horizontalSpacing == value) return; + _horizontalSpacing = value; + markNeedsLayout(); + } + + double get verticalSpacing => _verticalSpacing; + double _verticalSpacing; + set verticalSpacing(double value) { + if (_verticalSpacing == value) return; + _verticalSpacing = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + @override + bool get sizedByParent => false; + + static double _minWidth(RenderBox? box, double height) { + return box == null ? 0 : box.getMinIntrinsicWidth(height); + } + + static double _maxWidth(RenderBox? box, double height) { + return box == null ? 0 : box.getMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicWidth(double height) { + return _minWidth(leading, height) + + horizontalSpacing + + math.max(_minWidth(title, height), _minWidth(subtitle, height)); + } + + @override + double computeMaxIntrinsicWidth(double height) { + return _maxWidth(leading, height) + + horizontalSpacing + + math.max(_maxWidth(title, height), _maxWidth(subtitle, height)); + } + + @override + double computeMinIntrinsicHeight(double width) { + final subtitleHeight = subtitle != null + ? subtitle!.getMinIntrinsicHeight(width) + verticalSpacing + : 0; + return title!.getMinIntrinsicHeight(width) + subtitleHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return computeMinIntrinsicHeight(width); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + final parentData = title!.parentData! as BoxParentData; + return parentData.offset.dy + title!.getDistanceToActualBaseline(baseline)!; + } + + static Size _layoutBox(RenderBox? box, BoxConstraints constraints) { + if (box == null) return Size.zero; + box.layout(constraints, parentUsesSize: true); + return box.size; + } + + static void _positionBox(RenderBox box, Offset offset) { + final parentData = box.parentData! as BoxParentData; + parentData.offset = offset; + } + + @override + Size computeDryLayout(BoxConstraints constraints) => Size.zero; + + @override + void performLayout() { + final loosened = constraints.loosen(); + final availableWidth = loosened.maxWidth; + + final leadingSize = _layoutBox(leading, loosened); + assert( + availableWidth != leadingSize.width || availableWidth == 0, + 'ToggleButton.leading widget consumes entire button width.', + ); + + final titleX = leadingSize.width + horizontalSpacing; + final textConstraints = loosened.tighten(width: availableWidth - titleX); + + final titleSize = _layoutBox(title, textConstraints); + final subtitleSize = _layoutBox(subtitle, textConstraints); + + final leadingY = math.max(0.0, (titleSize.height - leadingSize.height) / 2); + final titleY = math.max(0.0, (leadingSize.height - titleSize.height) / 2); + final subtitleY = titleY + titleSize.height + verticalSpacing; + + final buttonWidth = leadingSize.width + + horizontalSpacing + + math.max(titleSize.width, subtitleSize.width); + + final buttonHeight = math.max( + leadingSize.height, + subtitle != null + ? subtitleY + subtitleSize.height + : titleY + titleSize.height, + ); + + switch (textDirection) { + case TextDirection.rtl: + if (leading != null) { + _positionBox( + leading!, + Offset(buttonWidth - leadingSize.width, leadingY), + ); + } + _positionBox(title!, Offset(0, titleY)); + if (subtitle != null) { + _positionBox(subtitle!, Offset(0, subtitleY)); + } + break; + case TextDirection.ltr: + if (leading != null) { + _positionBox(leading!, Offset(0, leadingY)); + } + _positionBox(title!, Offset(titleX, titleY)); + if (subtitle != null) { + _positionBox(subtitle!, Offset(titleX, subtitleY)); + } + break; + } + + size = constraints.constrain(Size(buttonWidth, buttonHeight)); + assert(size.width == constraints.constrainWidth(buttonWidth)); + assert(size.height == constraints.constrainHeight(buttonHeight)); + } + + @override + void paint(PaintingContext context, Offset offset) { + void doPaint(RenderBox? child) { + if (child != null) { + final parentData = child.parentData! as BoxParentData; + context.paintChild(child, parentData.offset + offset); + } + } + + doPaint(leading); + doPaint(title); + doPaint(subtitle); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + for (final child in children) { + final parentData = child.parentData! as BoxParentData; + final isHit = result.addWithPaintOffset( + offset: parentData.offset, + position: position, + hitTest: (result, transformed) { + assert(transformed == position - parentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) return true; + } + return false; + } +} diff --git a/lib/src/controls/yaru_toggle_button_theme.dart b/lib/src/controls/yaru_toggle_button_theme.dart new file mode 100644 index 000000000..0871b1fa0 --- /dev/null +++ b/lib/src/controls/yaru_toggle_button_theme.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Defines default property values for descendant [ToggleButton] widgets. +/// +/// Descendant widgets obtain the current [YaruToggleButtonThemeData] object +/// using `YaruToggleButtonTheme.of(context)`. Instances of [YaruToggleButtonThemeData] +/// can be customized with [YaruToggleButtonThemeData.copyWith]. +@immutable +class YaruToggleButtonThemeData with Diagnosticable { + /// Creates a theme that can be used for [YaruToggleButtonTheme.data]. + const YaruToggleButtonThemeData({ + this.horizontalSpacing, + this.verticalSpacing, + }); + + /// The spacing between the indicator and the title. + final double? horizontalSpacing; + + /// The spacing between the title and the subtitle. + final double? verticalSpacing; + + /// Creates a copy with the given fields replaced with new values. + YaruToggleButtonThemeData copyWith({ + double? horizontalSpacing, + double? verticalSpacing, + }) { + return YaruToggleButtonThemeData( + horizontalSpacing: horizontalSpacing ?? this.horizontalSpacing, + verticalSpacing: verticalSpacing ?? this.verticalSpacing, + ); + } + + @override + int get hashCode => Object.hash(horizontalSpacing, verticalSpacing); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is YaruToggleButtonThemeData && + other.horizontalSpacing == horizontalSpacing && + other.verticalSpacing == verticalSpacing; + } +} + +/// Applies a theme to descendant [ToggleButton] widgets. +/// +/// Descendant widgets obtain the current [YaruToggleButtonTheme] object using +/// [YaruToggleButtonTheme.of]. When a widget uses [YaruToggleButtonTheme.of], +/// it is automatically rebuilt if the theme later changes. +/// +/// See also: +/// +/// * [YaruToggleButtonThemeData], which describes the actual configuration of +/// a toggle button theme. +class YaruToggleButtonTheme extends InheritedWidget { + /// Constructs a checkbox theme that configures all descendant [ToggleButton] + /// widgets. + const YaruToggleButtonTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The properties used for all descendant [ToggleButton] widgets. + final YaruToggleButtonThemeData data; + + /// Returns the configuration [data] from the closest [YaruToggleButtonTheme] + /// ancestor. If there is no ancestor, it returns `null`. + static YaruToggleButtonThemeData? of(BuildContext context) { + final t = + context.dependOnInheritedWidgetOfExactType(); + return t?.data; + } + + @override + bool updateShouldNotify(YaruToggleButtonTheme oldWidget) { + return data != oldWidget.data; + } +} diff --git a/lib/yaru_widgets.dart b/lib/yaru_widgets.dart index 6d31f8a06..ebe4d2b14 100644 --- a/lib/yaru_widgets.dart +++ b/lib/yaru_widgets.dart @@ -4,10 +4,14 @@ library yaru_widgets; export 'src/constants.dart'; // Controls export 'src/controls/yaru_back_button.dart'; +export 'src/controls/yaru_check_button.dart'; export 'src/controls/yaru_color_disk.dart'; export 'src/controls/yaru_icon_button.dart'; export 'src/controls/yaru_option_button.dart'; export 'src/controls/yaru_progress_indicator.dart'; +export 'src/controls/yaru_radio_button.dart'; +export 'src/controls/yaru_toggle_button.dart'; +export 'src/controls/yaru_toggle_button_theme.dart'; // Dialogs export 'src/dialogs/yaru_dialog_title.dart'; // Extensions diff --git a/test/controls/yaru_check_button_test.dart b/test/controls/yaru_check_button_test.dart new file mode 100644 index 000000000..40c77dd25 --- /dev/null +++ b/test/controls/yaru_check_button_test.dart @@ -0,0 +1,146 @@ +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'; + +void main() { + testWidgets('contains checkbox and labels', (tester) async { + Widget builder({required Widget title, required Widget? subtitle}) { + return MaterialApp( + home: Scaffold( + body: YaruCheckButton( + title: title, + subtitle: subtitle, + value: false, + onChanged: (_) {}, + ), + ), + ); + } + + await tester + .pumpWidget(builder(title: const Text('title'), subtitle: null)); + expect(find.text('title'), findsOneWidget); + expect(find.text('subtitle'), findsNothing); + expect(find.byType(Checkbox), 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(Checkbox), findsOneWidget); + }); + + testWidgets('the labels react to taps', (tester) async { + bool? changedValue; + Widget builder({required bool initialValue}) { + return MaterialApp( + home: Scaffold( + body: YaruCheckButton( + title: const Text('title'), + subtitle: const Text('subtitle'), + value: initialValue, + onChanged: (v) => changedValue = v, + ), + ), + ); + } + + await tester.pumpWidget(builder(initialValue: false)); + await tester.tap(find.text('title')); + expect(changedValue, isTrue); + + await tester.pumpWidget(builder(initialValue: false)); + await tester.tap(find.text('subtitle')); + expect(changedValue, isTrue); + + await tester.pumpWidget(builder(initialValue: true)); + await tester.tap(find.text('title')); + expect(changedValue, isFalse); + + await tester.pumpWidget(builder(initialValue: true)); + await tester.tap(find.text('subtitle')); + expect(changedValue, isFalse); + }); + + testWidgets('mouse cursor changes depending on the state', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + YaruCheckButton( + title: const Text('enabled'), + value: false, + onChanged: (_) {}, + ), + const YaruCheckButton( + title: Text('disabled'), + 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.widgetWithText(MouseRegion, 'enabled'))); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + await gesture + .moveTo(tester.getCenter(find.widgetWithText(MouseRegion, 'disabled'))); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('text color changes depending on the state', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + YaruCheckButton( + title: const Text('enabled'), + value: false, + onChanged: (_) {}, + ), + const YaruCheckButton( + title: Text('disabled'), + value: false, + onChanged: null, + ), + ], + ), + ), + ), + ); + + final enabled = tester.element(find.text('enabled')); + expect( + DefaultTextStyle.of(enabled).style.color, + isNot(equals(Theme.of(enabled).disabledColor)), + ); + + final disabled = tester.element(find.text('disabled')); + expect( + DefaultTextStyle.of(disabled).style.color, + equals(Theme.of(disabled).disabledColor), + ); + }); +} diff --git a/test/controls/yaru_radio_button_test.dart b/test/controls/yaru_radio_button_test.dart new file mode 100644 index 000000000..de6d2ff37 --- /dev/null +++ b/test/controls/yaru_radio_button_test.dart @@ -0,0 +1,144 @@ +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'; + +void main() { + testWidgets('contains radio and labels', (tester) async { + Widget builder({required Widget title, required Widget? subtitle}) { + return MaterialApp( + home: Scaffold( + body: YaruRadioButton( + title: title, + subtitle: subtitle, + value: 0, + groupValue: 0, + onChanged: (_) {}, + ), + ), + ); + } + + await tester + .pumpWidget(builder(title: const Text('title'), subtitle: null)); + expect(find.text('title'), findsOneWidget); + expect(find.text('subtitle'), findsNothing); + expect(find.byType(Radio), 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(Radio), findsOneWidget); + }); + + testWidgets('the labels react to taps', (tester) async { + int? changedValue; + Widget builder({required int value, required int groupValue}) { + return MaterialApp( + home: Scaffold( + body: YaruRadioButton( + title: const Text('title'), + subtitle: const Text('subtitle'), + value: value, + groupValue: groupValue, + onChanged: (v) => changedValue = v, + ), + ), + ); + } + + await tester.pumpWidget(builder(value: 1, groupValue: 1)); + await tester.tap(find.text('title')); + expect(changedValue, equals(1)); + + await tester.pumpWidget(builder(value: 2, groupValue: 3)); + await tester.tap(find.text('subtitle')); + expect(changedValue, equals(2)); + }); + + testWidgets('mouse cursor changes depending on the state', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + YaruRadioButton( + title: const Text('enabled'), + value: 0, + groupValue: 0, + onChanged: (_) {}, + ), + const YaruRadioButton( + title: Text('disabled'), + value: 0, + groupValue: 0, + 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.widgetWithText(MouseRegion, 'enabled'))); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + await gesture + .moveTo(tester.getCenter(find.widgetWithText(MouseRegion, 'disabled'))); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('text color changes depending on the state', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + YaruRadioButton( + title: const Text('enabled'), + value: 0, + groupValue: 0, + onChanged: (_) {}, + ), + const YaruRadioButton( + title: Text('disabled'), + value: 0, + groupValue: 0, + onChanged: null, + ), + ], + ), + ), + ), + ); + + final enabled = tester.element(find.text('enabled')); + expect( + DefaultTextStyle.of(enabled).style.color, + isNot(equals(Theme.of(enabled).disabledColor)), + ); + + final disabled = tester.element(find.text('disabled')); + expect( + DefaultTextStyle.of(disabled).style.color, + equals(Theme.of(disabled).disabledColor), + ); + }); +} diff --git a/test/controls/yaru_toggle_button_test.dart b/test/controls/yaru_toggle_button_test.dart new file mode 100644 index 000000000..b01678d7d --- /dev/null +++ b/test/controls/yaru_toggle_button_test.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru_widgets/yaru_widgets.dart'; + +void main() { + testWidgets('narrow title', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 48, height: 48), + title: SizedBox(key: title, width: 128, height: 24), + ), + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + + expect(buttonRect.width, greaterThan(48 + 128)); + expect(buttonRect.height, 48); + + expect(leadingRect.left, buttonRect.left); + expect(leadingRect.top, buttonRect.top); + expect(leadingRect.width, 48); + expect(leadingRect.height, 48); + + expect(titleRect.left, greaterThan(leadingRect.right)); + expect(titleRect.center.dy, leadingRect.center.dy); + expect(titleRect.right, buttonRect.right); + expect(titleRect.width, 128); + expect(titleRect.height, 24); + }); + + testWidgets('tall title', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 24, height: 24), + title: SizedBox(key: title, width: 128, height: 48), + ), + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + + expect(buttonRect.width, greaterThan(24 + 128)); + expect(buttonRect.height, 48); + + expect(leadingRect.left, buttonRect.left); + expect(leadingRect.center.dy, titleRect.center.dy); + expect(leadingRect.width, 24); + expect(leadingRect.height, 24); + + expect(titleRect.left, greaterThan(leadingRect.right)); + expect(titleRect.top, buttonRect.top); + expect(titleRect.right, buttonRect.right); + expect(titleRect.width, 128); + expect(titleRect.height, 48); + }); + + testWidgets('subtitle', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + const subtitle = Key('subtitle'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 48, height: 48), + title: SizedBox(key: title, width: 128, height: 24), + subtitle: SizedBox(key: subtitle, width: 192, height: 16), + ), + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + final subtitleRect = tester.getRect(find.byKey(subtitle)); + + expect(buttonRect.width, greaterThan(48 + 192)); + expect(buttonRect.height, greaterThan(48)); + + expect(leadingRect.left, buttonRect.left); + expect(leadingRect.top, buttonRect.top); + expect(leadingRect.width, 48); + expect(leadingRect.height, 48); + + expect(titleRect.left, greaterThan(leadingRect.right)); + expect(titleRect.center.dy, leadingRect.center.dy); + expect(titleRect.right, buttonRect.right); + expect(titleRect.width, 192); + expect(titleRect.height, 24); + + expect(subtitleRect.left, titleRect.left); + expect(subtitleRect.top, greaterThan(titleRect.bottom)); + expect(subtitleRect.right, titleRect.right); + expect(subtitleRect.bottom, buttonRect.bottom); + expect(subtitleRect.width, 192); + expect(subtitleRect.height, 16); + }); + + testWidgets('narrow rtl title', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 48, height: 48), + title: SizedBox(key: title, width: 128, height: 24), + ), + textDirection: TextDirection.rtl, + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + + expect(buttonRect.width, greaterThan(48 + 128)); + expect(buttonRect.height, 48); + + expect(leadingRect.right, buttonRect.right); + expect(leadingRect.top, buttonRect.top); + expect(leadingRect.width, 48); + expect(leadingRect.height, 48); + + expect(titleRect.right, lessThan(leadingRect.left)); + expect(titleRect.center.dy, leadingRect.center.dy); + expect(titleRect.left, buttonRect.left); + expect(titleRect.width, 128); + expect(titleRect.height, 24); + }); + + testWidgets('tall rtl title', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 24, height: 24), + title: SizedBox(key: title, width: 128, height: 48), + ), + textDirection: TextDirection.rtl, + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + + expect(buttonRect.width, greaterThan(24 + 128)); + expect(buttonRect.height, 48); + + expect(leadingRect.right, buttonRect.right); + expect(leadingRect.center.dy, titleRect.center.dy); + expect(leadingRect.width, 24); + expect(leadingRect.height, 24); + + expect(titleRect.left, buttonRect.left); + expect(titleRect.top, buttonRect.top); + expect(titleRect.right, lessThan(leadingRect.left)); + expect(titleRect.width, 128); + expect(titleRect.height, 48); + }); + + testWidgets('rtl subtitle', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + const subtitle = Key('subtitle'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 48, height: 48), + title: SizedBox(key: title, width: 128, height: 24), + subtitle: SizedBox(key: subtitle, width: 192, height: 16), + ), + textDirection: TextDirection.rtl, + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + final subtitleRect = tester.getRect(find.byKey(subtitle)); + + expect(buttonRect.width, greaterThan(48 + 192)); + expect(buttonRect.height, greaterThan(48)); + + expect(leadingRect.right, buttonRect.right); + expect(leadingRect.top, buttonRect.top); + expect(leadingRect.width, 48); + expect(leadingRect.height, 48); + + expect(titleRect.left, buttonRect.left); + expect(titleRect.center.dy, leadingRect.center.dy); + expect(titleRect.right, lessThan(leadingRect.left)); + expect(titleRect.width, 192); + expect(titleRect.height, 24); + + expect(subtitleRect.left, titleRect.left); + expect(subtitleRect.top, greaterThan(titleRect.bottom)); + expect(subtitleRect.right, titleRect.right); + expect(subtitleRect.bottom, buttonRect.bottom); + expect(subtitleRect.width, 192); + expect(subtitleRect.height, 16); + }); + + testWidgets('theme spacing', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + const subtitle = Key('subtitle'); + + await tester.pumpToggleButton( + const YaruToggleButtonTheme( + data: YaruToggleButtonThemeData( + horizontalSpacing: 24, + verticalSpacing: 12, + ), + child: YaruToggleButton( + leading: SizedBox(key: leading, width: 48, height: 48), + title: SizedBox(key: title, width: 128, height: 24), + subtitle: SizedBox(key: subtitle, width: 192, height: 16), + ), + ), + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + final subtitleRect = tester.getRect(find.byKey(subtitle)); + + expect(buttonRect.width, 48 + 24 + 192); + expect(buttonRect.height, 24 + 12 + 12 + 16); + + expect(leadingRect.left, buttonRect.left); + expect(leadingRect.top, buttonRect.top); + expect(leadingRect.width, 48); + expect(leadingRect.height, 48); + + expect(titleRect.left, leadingRect.right + 24); + expect(titleRect.center.dy, leadingRect.center.dy); + expect(titleRect.right, buttonRect.right); + expect(titleRect.width, 192); + expect(titleRect.height, 24); + + expect(subtitleRect.left, titleRect.left); + expect(subtitleRect.top, titleRect.bottom + 12); + expect(subtitleRect.right, buttonRect.right); + expect(subtitleRect.bottom, buttonRect.bottom); + expect(subtitleRect.width, 192); + expect(subtitleRect.height, 16); + }); + + testWidgets('rebuild', (tester) async { + const leading = Key('leading'); + const title = Key('title'); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 24, height: 24), + title: SizedBox(key: title, width: 96, height: 48), + ), + ); + + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox(key: leading, width: 48, height: 48), + title: SizedBox(key: title, width: 128, height: 24), + ), + ); + + final buttonRect = tester.getRect(find.byType(YaruToggleButton)); + final leadingRect = tester.getRect(find.byKey(leading)); + final titleRect = tester.getRect(find.byKey(title)); + + expect(buttonRect.width, greaterThan(48 + 128)); + expect(buttonRect.height, 48); + + expect(leadingRect.left, buttonRect.left); + expect(leadingRect.top, buttonRect.top); + expect(leadingRect.width, 48); + expect(leadingRect.height, 48); + + expect(titleRect.left, greaterThan(leadingRect.right)); + expect(titleRect.center.dy, leadingRect.center.dy); + expect(titleRect.right, buttonRect.right); + expect(titleRect.width, 128); + expect(titleRect.height, 24); + }); + + testWidgets('theme data', (tester) async { + const data = YaruToggleButtonThemeData( + horizontalSpacing: 1.2, + verticalSpacing: 2.3, + ); + expect(data, data.copyWith()); + expect(data, isNot(data.copyWith(horizontalSpacing: 3.4))); + expect( + data.copyWith(verticalSpacing: 3.4), + data.copyWith(verticalSpacing: 3.4), + ); + }); + + testWidgets('ellipsize and wrap', (tester) async { + await tester.pumpToggleButton( + const YaruToggleButton( + leading: SizedBox.shrink(), + title: Text('title'), + subtitle: Text('subtitle'), + ), + ); + + final title = DefaultTextStyle.of(tester.element(find.text('title'))); + expect(title.softWrap, isFalse); + expect(title.overflow, TextOverflow.ellipsis); + + final subtitle = DefaultTextStyle.of(tester.element(find.text('subtitle'))); + expect(subtitle.softWrap, isTrue); + expect(subtitle.overflow, isNot(TextOverflow.ellipsis)); + }); +} + +extension YaruToggleButtonTester on WidgetTester { + Future pumpToggleButton( + Widget widget, { + TextDirection textDirection = TextDirection.ltr, + }) { + final app = MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: IntrinsicWidth(child: widget), + ), + ), + ), + ); + return pumpWidget(app); + } +}