diff --git a/forui/example/pubspec.lock b/forui/example/pubspec.lock index d81de869b..562d69968 100644 --- a/forui/example/pubspec.lock +++ b/forui/example/pubspec.lock @@ -406,10 +406,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.5" path_provider_foundation: dependency: transitive description: @@ -478,10 +478,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.3.0" shelf: dependency: transitive description: @@ -627,10 +627,10 @@ packages: dependency: transitive description: name: web_socket - sha256: "217f49b5213796cb508d6a942a5dc604ce1cb6a0a6b3d8cb3f0c314f0ecea712" + sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "0.1.5" web_socket_channel: dependency: transitive description: @@ -665,4 +665,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.2" + flutter: ">=3.22.0" diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart index 0b1f7b650..f5954409e 100644 --- a/forui/lib/forui.dart +++ b/forui/lib/forui.dart @@ -17,3 +17,4 @@ export 'src/widgets/box.dart'; export 'src/widgets/badge/badge.dart' hide FBadgeContent; export 'src/widgets/card/card.dart' hide FCardContent; export 'src/widgets/separator.dart'; +export 'src/widgets/switch.dart'; diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart index e6ea5c405..8424ed20c 100644 --- a/forui/lib/src/theme/theme_data.dart +++ b/forui/lib/src/theme/theme_data.dart @@ -25,6 +25,9 @@ class FThemeData with Diagnosticable { /// The separator styles. final FSeparatorStyles separatorStyles; + /// The switch style. + final FSwitchStyle switchStyle; + /// Creates a [FThemeData]. FThemeData({ required this.colorScheme, @@ -34,6 +37,7 @@ class FThemeData with Diagnosticable { required this.boxStyle, required this.cardStyle, required this.separatorStyles, + required this.switchStyle, }); /// Creates a [FThemeData] that inherits the given arguments' properties. @@ -45,7 +49,8 @@ class FThemeData with Diagnosticable { badgeStyles = FBadgeStyles.inherit(colorScheme: colorScheme, font: font, style: style), boxStyle = FBoxStyle.inherit(colorScheme: colorScheme), cardStyle = FCardStyle.inherit(colorScheme: colorScheme, font: font, style: style), - separatorStyles = FSeparatorStyles.inherit(colorScheme: colorScheme, style: style); + separatorStyles = FSeparatorStyles.inherit(colorScheme: colorScheme, style: style), + switchStyle = FSwitchStyle.inherit(colorScheme: colorScheme); /// Creates a copy of this [FThemeData] with the given properties replaced. FThemeData copyWith({ @@ -56,6 +61,7 @@ class FThemeData with Diagnosticable { FBoxStyle? boxStyle, FCardStyle? cardStyle, FSeparatorStyles? separatorStyles, + FSwitchStyle? switchStyle, }) => FThemeData( colorScheme: colorScheme ?? this.colorScheme, font: font ?? this.font, @@ -64,6 +70,7 @@ class FThemeData with Diagnosticable { boxStyle: boxStyle ?? this.boxStyle, cardStyle: cardStyle ?? this.cardStyle, separatorStyles: separatorStyles ?? this.separatorStyles, + switchStyle: switchStyle ?? this.switchStyle, ); @override @@ -76,7 +83,8 @@ class FThemeData with Diagnosticable { ..add(DiagnosticsProperty('badgeStyles', badgeStyles, level: DiagnosticLevel.debug)) ..add(DiagnosticsProperty('boxStyle', boxStyle, level: DiagnosticLevel.debug)) ..add(DiagnosticsProperty('cardStyle', cardStyle, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('separatorStyles', separatorStyles, level: DiagnosticLevel.debug)); + ..add(DiagnosticsProperty('separatorStyles', separatorStyles, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('switchStyle', switchStyle)); } @override @@ -90,7 +98,8 @@ class FThemeData with Diagnosticable { badgeStyles == other.badgeStyles && boxStyle == other.boxStyle && cardStyle == other.cardStyle && - separatorStyles == other.separatorStyles; + separatorStyles == other.separatorStyles && + switchStyle == other.switchStyle; @override int get hashCode => @@ -100,5 +109,6 @@ class FThemeData with Diagnosticable { badgeStyles.hashCode ^ boxStyle.hashCode ^ cardStyle.hashCode ^ - separatorStyles.hashCode; + separatorStyles.hashCode ^ + switchStyle.hashCode; } diff --git a/forui/lib/src/widgets/switch.dart b/forui/lib/src/widgets/switch.dart new file mode 100644 index 000000000..44fc26e8b --- /dev/null +++ b/forui/lib/src/widgets/switch.dart @@ -0,0 +1,177 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; + +import 'package:forui/forui.dart'; + +/// A control that allows the user to toggle between checked and not checked. +class FSwitch extends StatelessWidget { + + /// The style of the switch. + final FSwitchStyle? style; + + /// Whether this switch is on or off. + final bool value; + + /// Called when the user toggles with switch on or off. + /// + /// The switch passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the switch with the new + /// value. + /// + /// If null, the switch will be displayed as disabled, which has a reduced opacity. + /// + /// The callback provided to onChanged should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// FSwitch( + /// value: _giveVerse, + /// onChanged: (bool newValue) { + /// setState(() { + /// _giveVerse = newValue; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged? onChanged; + + /// True if this widget will be selected as the initial focus when no other node in its scope is currently focused. + /// + /// Ideally, there is only one widget with autofocus set in each FocusScope. If there is more than one widget with + /// autofocus set, then the first one added to the tree will get focus. + /// + /// Defaults to false. + final bool autofocus; + + /// An optional focus node to use as the focus node for this widget. + /// + /// If one is not supplied, then one will be automatically allocated, owned, and managed by this widget. The widget + /// will be focusable even if a [focusNode] is not supplied. If supplied, the given `focusNode` will be hosted by this + /// widget, but not owned. See [FocusNode] for more information on what being hosted and/or owned implies. + /// + /// Supplying a focus node is sometimes useful if an ancestor to this widget wants to control when this widget has the + /// focus. The owner will be responsible for calling [FocusNode.dispose] on the focus node when it is done with it, + /// but this widget will attach/detach and reparent the node when needed. + final FocusNode? focusNode; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses focus. + final ValueChanged? onFocusChange; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag behavior used to move the + /// switch from on to off will begin at the position where the drag gesture won + /// the arena. If set to [DragStartBehavior.down] it will begin at the position + /// where a down event was first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + final DragStartBehavior dragStartBehavior; + + /// Creates a [FSwitch]. + const FSwitch({ + required this.value, + required this.onChanged, + this.style, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.dragStartBehavior = DragStartBehavior.start, + super.key, + }); + + @override + Widget build(BuildContext context) { + final style = this.style ?? context.theme.switchStyle; + return CupertinoSwitch( + value: value, + onChanged: onChanged, + activeColor: style.checked, + trackColor: style.unchecked, + thumbColor: style.thumb, + focusColor: style.focus, + autofocus: autofocus, + focusNode: focusNode, + onFocusChange: onFocusChange, + dragStartBehavior: dragStartBehavior, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('value', value)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(DiagnosticsProperty('dragStartBehavior', dragStartBehavior)) + ..add(DiagnosticsProperty('style', style)) + ..add(DiagnosticsProperty>('onChanged', onChanged)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(DiagnosticsProperty>('onFocusChange', onFocusChange)); + } +} + +/// The style of a [FSwitch]. +final class FSwitchStyle with Diagnosticable { + + /// The color of the switch when it is checked. + final Color checked; + + /// The color of the switch when it is unchecked. + final Color unchecked; + + /// The color of the switch's thumb. + final Color thumb; + + /// The color of the switch when it is focused. Defaults to a slightly transparent [checked] color. + final Color focus; + + /// Creates a [FSwitchStyle]. + const FSwitchStyle({ + required this.checked, + required this.unchecked, + required this.thumb, + required this.focus, + }); + + /// Creates a [FSwitchStyle] that inherits its properties from [colorScheme]. + FSwitchStyle.inherit({required FColorScheme colorScheme}) + : checked = colorScheme.primary, + unchecked = colorScheme.border, + thumb = colorScheme.background, + focus = HSLColor.fromColor(colorScheme.primary.withOpacity(0.80)) + .withLightness(0.69) + .withSaturation(0.835) + .toColor(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('checked', checked)) + ..add(ColorProperty('unchecked', unchecked)) + ..add(ColorProperty('thumb', thumb)) + ..add(ColorProperty('focus', focus)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FSwitchStyle && + runtimeType == other.runtimeType && + checked == other.checked && + unchecked == other.unchecked && + thumb == other.thumb && + focus == other.focus; + + @override + int get hashCode => checked.hashCode ^ unchecked.hashCode ^ thumb.hashCode ^ focus.hashCode; + +} diff --git a/forui/test/golden/switch/zinc-dark-checked-disabled.png b/forui/test/golden/switch/zinc-dark-checked-disabled.png new file mode 100644 index 000000000..9edb34764 Binary files /dev/null and b/forui/test/golden/switch/zinc-dark-checked-disabled.png differ diff --git a/forui/test/golden/switch/zinc-dark-checked-focused.png b/forui/test/golden/switch/zinc-dark-checked-focused.png new file mode 100644 index 000000000..6e215403c Binary files /dev/null and b/forui/test/golden/switch/zinc-dark-checked-focused.png differ diff --git a/forui/test/golden/switch/zinc-dark-checked-unfocused.png b/forui/test/golden/switch/zinc-dark-checked-unfocused.png new file mode 100644 index 000000000..6e215403c Binary files /dev/null and b/forui/test/golden/switch/zinc-dark-checked-unfocused.png differ diff --git a/forui/test/golden/switch/zinc-dark-unchecked-disabled.png b/forui/test/golden/switch/zinc-dark-unchecked-disabled.png new file mode 100644 index 000000000..3c075c60a Binary files /dev/null and b/forui/test/golden/switch/zinc-dark-unchecked-disabled.png differ diff --git a/forui/test/golden/switch/zinc-dark-unchecked-focused.png b/forui/test/golden/switch/zinc-dark-unchecked-focused.png new file mode 100644 index 000000000..c77c254de Binary files /dev/null and b/forui/test/golden/switch/zinc-dark-unchecked-focused.png differ diff --git a/forui/test/golden/switch/zinc-dark-unchecked-unfocused.png b/forui/test/golden/switch/zinc-dark-unchecked-unfocused.png new file mode 100644 index 000000000..c77c254de Binary files /dev/null and b/forui/test/golden/switch/zinc-dark-unchecked-unfocused.png differ diff --git a/forui/test/golden/switch/zinc-light-checked-disabled.png b/forui/test/golden/switch/zinc-light-checked-disabled.png new file mode 100644 index 000000000..8d161b3b9 Binary files /dev/null and b/forui/test/golden/switch/zinc-light-checked-disabled.png differ diff --git a/forui/test/golden/switch/zinc-light-checked-focused.png b/forui/test/golden/switch/zinc-light-checked-focused.png new file mode 100644 index 000000000..27ef8b8ce Binary files /dev/null and b/forui/test/golden/switch/zinc-light-checked-focused.png differ diff --git a/forui/test/golden/switch/zinc-light-checked-unfocused.png b/forui/test/golden/switch/zinc-light-checked-unfocused.png new file mode 100644 index 000000000..27ef8b8ce Binary files /dev/null and b/forui/test/golden/switch/zinc-light-checked-unfocused.png differ diff --git a/forui/test/golden/switch/zinc-light-unchecked-disabled.png b/forui/test/golden/switch/zinc-light-unchecked-disabled.png new file mode 100644 index 000000000..db304ca9b Binary files /dev/null and b/forui/test/golden/switch/zinc-light-unchecked-disabled.png differ diff --git a/forui/test/golden/switch/zinc-light-unchecked-focused.png b/forui/test/golden/switch/zinc-light-unchecked-focused.png new file mode 100644 index 000000000..d952fa6e2 Binary files /dev/null and b/forui/test/golden/switch/zinc-light-unchecked-focused.png differ diff --git a/forui/test/golden/switch/zinc-light-unchecked-unfocused.png b/forui/test/golden/switch/zinc-light-unchecked-unfocused.png new file mode 100644 index 000000000..d952fa6e2 Binary files /dev/null and b/forui/test/golden/switch/zinc-light-unchecked-unfocused.png differ diff --git a/forui/test/src/widgets/switch_test.dart b/forui/test/src/widgets/switch_test.dart new file mode 100644 index 000000000..f575b9c00 --- /dev/null +++ b/forui/test/src/widgets/switch_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/cupertino.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; +import '../test_scaffold.dart'; + +void main() { + group('FSeparator', () { + for (final (name, theme, _) in TestScaffold.themes) { + for (final (checked, value) in [('checked', true), ('unchecked', false)]) { + testWidgets('$name - $checked - unfocused', (tester) async { + await tester.pumpWidget( + TestScaffold( + data: theme, + child: Center( + child: FSwitch( + value: value, + onChanged: (_) {}, + ), + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('switch/$name-$checked-unfocused.png'), + ); + }); + + testWidgets('$name - $checked - focused', (tester) async { + await tester.pumpWidget( + TestScaffold( + data: theme, + child: Center( + child: FSwitch( + value: value, + autofocus: true, + onChanged: (_) {}, + ), + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('switch/$name-$checked-focused.png'), + ); + }); + + testWidgets('$name - $checked - disabled', (tester) async { + await tester.pumpWidget( + TestScaffold( + data: theme, + child: Center( + child: FSwitch( + value: value, + autofocus: true, + onChanged: null, + ), + ), + ), + ); + + await expectLater( + find.byType(TestScaffold), + matchesGoldenFile('switch/$name-$checked-disabled.png'), + ); + }); + } + } + }); +}