diff --git a/example/lib/home.dart b/example/lib/home.dart index 2df43f7c..d09cdcca 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -13,6 +13,7 @@ import 'package:zeta_example/pages/components/dialpad_example.dart'; import 'package:zeta_example/pages/components/dropdown_example.dart'; import 'package:zeta_example/pages/components/list_item_example.dart'; import 'package:zeta_example/pages/components/navigation_bar_example.dart'; +import 'package:zeta_example/pages/components/radio_example.dart'; import 'package:zeta_example/pages/components/switch_example.dart'; import 'package:zeta_example/pages/theme/color_example.dart'; import 'package:zeta_example/pages/components/password_input_example.dart'; @@ -47,6 +48,7 @@ final List components = [ Component(DropdownExample.name, (context) => const DropdownExample()), Component(ProgressExample.name, (context) => const ProgressExample()), Component(DialPadExample.name, (context) => const DialPadExample()), + Component(RadioButtonExample.name, (context) => const RadioButtonExample()), Component(SwitchExample.name, (context) => const SwitchExample()), ]; diff --git a/example/lib/pages/components/radio_example.dart b/example/lib/pages/components/radio_example.dart new file mode 100644 index 00000000..bd7b5f26 --- /dev/null +++ b/example/lib/pages/components/radio_example.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class RadioButtonExample extends StatefulWidget { + static const String name = 'RadioButton'; + + const RadioButtonExample({Key? key}) : super(key: key); + + @override + State createState() => _RadioButtonExampleState(); +} + +class _RadioButtonExampleState extends State { + String option1 = 'Label 1'; + String option2 = 'Label 2'; + String? groupValue; + bool isEnabled = true; + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: 'Radio Button', + child: Center( + child: Column( + children: [ + ZetaRadio( + value: option1, + groupValue: groupValue, + onChanged: isEnabled ? (value) => setState(() => groupValue = value) : null, + label: Text(option1), + ), + ZetaRadio( + value: option2, + groupValue: groupValue, + onChanged: isEnabled ? (value) => setState(() => groupValue = value) : null, + label: Text(option2), + ), + ZetaButton( + label: isEnabled ? 'Disable' : 'Enable', + onPressed: () => setState(() => isEnabled = !isEnabled), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 76cd3f65..d8f71422 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -19,6 +19,7 @@ import 'pages/components/list_item_widgetbook.dart'; import 'pages/components/navigation_bar_widgetbook.dart'; import 'pages/components/password_input_widgetbook.dart'; import 'pages/components/progress_widgetbook.dart'; +import 'pages/components/radio_widgetbook.dart'; import 'pages/components/switch_widgetbook.dart'; import 'pages/theme/color_widgetbook.dart'; import 'pages/theme/radius_widgetbook.dart'; @@ -89,6 +90,7 @@ class HotReload extends StatelessWidget { WidgetbookUseCase(name: 'Circle', builder: (context) => progressCircleUseCase(context)) ], ), + WidgetbookUseCase(name: 'Radio Button', builder: (context) => radioButtonUseCase(context)), WidgetbookUseCase(name: 'Switch', builder: (context) => switchUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), ), diff --git a/example/widgetbook/pages/components/radio_widgetbook.dart b/example/widgetbook/pages/components/radio_widgetbook.dart new file mode 100644 index 00000000..e8aacdc4 --- /dev/null +++ b/example/widgetbook/pages/components/radio_widgetbook.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget radioButtonUseCase(BuildContext context) { + String option1 = 'Label 1'; + String option2 = 'Label 2'; + String? groupValue; + + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + ValueChanged? onChanged = context.knobs.boolean(label: 'Enabled', initialValue: true) + ? (value) => setState(() => groupValue = value) + : null; + return Padding( + padding: const EdgeInsets.all(ZetaSpacing.x5), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: ZetaSpacing.x5), + child: Text('Radio Button'), + ), + ZetaRadio( + value: option1, + groupValue: groupValue, + onChanged: onChanged, + label: Text(option1), + ), + ZetaRadio( + value: option2, + groupValue: groupValue, + onChanged: onChanged, + label: Text(option2), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/src/components/radio/radio.dart b/lib/src/components/radio/radio.dart new file mode 100644 index 00000000..82f2a61f --- /dev/null +++ b/lib/src/components/radio/radio.dart @@ -0,0 +1,157 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +/// Zeta Radio Button +/// +/// Radio Button can select one single option from a goup of different options. +class ZetaRadio extends StatefulWidget { + /// Constructor for [ZetaRadio]. + const ZetaRadio({ + super.key, + required this.value, + this.groupValue, + this.onChanged, + this.label, + }); + + /// The value of the option, which can be selected by this Radio Button. + final T value; + + /// The selected value among all possible options. + final T? groupValue; + + /// Callback function to call when the Radio Button is tapped. + final ValueChanged? onChanged; + + /// The label which appears next to the Radio Button, on the right side. + final Widget? label; + + bool get _selected => value == groupValue; + + @override + State> createState() => _ZetaRadioState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('value', value)) + ..add(DiagnosticsProperty('groupValue', groupValue)) + ..add(ObjectFlagProperty?>('onChanged', onChanged, ifNull: 'disabled')); + } +} + +class _ZetaRadioState extends State> with TickerProviderStateMixin, ToggleableStateMixin { + final ToggleablePainter _painter = _RadioPainter(); + @override + Widget build(BuildContext context) { + final zetaColors = Zeta.of(context).colors; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + inMutuallyExclusiveGroup: true, + checked: widget._selected, + selected: value, + child: buildToggleable( + size: const Size(36, 36), + painter: _painter + ..position = position + ..reaction = reaction + ..reactionFocusFade = reactionFocusFade + ..reactionHoverFade = reactionHoverFade + ..inactiveReactionColor = Colors.transparent + ..reactionColor = Colors.transparent + ..hoverColor = Colors.transparent + ..focusColor = zetaColors.blue.shade50 + ..splashRadius = 12 + ..downPosition = downPosition + ..isFocused = states.contains(MaterialState.focused) + ..isHovered = states.contains(MaterialState.hovered) + ..activeColor = + states.contains(MaterialState.disabled) ? zetaColors.cool.shade30 : zetaColors.blue.shade60 + ..inactiveColor = + states.contains(MaterialState.disabled) ? zetaColors.cool.shade30 : zetaColors.cool.shade70, + mouseCursor: MaterialStateProperty.all( + MaterialStateProperty.resolveAs( + MaterialStateMouseCursor.clickable, + states, + ), + ), + ), + ), + if (widget.label != null) + GestureDetector( + onTap: () => onChanged?.call(true), + child: DefaultTextStyle( + style: ZetaTextStyles.bodyLarge.copyWith( + color: states.contains(MaterialState.disabled) ? zetaColors.textDisabled : zetaColors.textDefault, + height: 1.33, + ), + child: widget.label!, + ), + ), + ], + ); + } + + void _handleChanged(bool? selected) { + if (selected == null) { + widget.onChanged!(null); + return; + } + if (selected) { + widget.onChanged!(widget.value); + } + } + + @override + ValueChanged? get onChanged => widget.onChanged != null ? _handleChanged : null; + + @override + bool get tristate => false; + + @override + bool get value => widget._selected; + + @override + void didUpdateWidget(ZetaRadio oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget._selected != oldWidget._selected) { + animateToValue(); + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } +} + +const double _kOuterRadius = 10; +const double _kInnerRadius = 5; + +class _RadioPainter extends ToggleablePainter { + @override + void paint(Canvas canvas, Size size) { + paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero)); + + final Offset center = (Offset.zero & size).center; + + // Outer circle + final Paint paint = Paint() + ..color = Color.lerp(inactiveColor, activeColor, position.value)! + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(center, _kOuterRadius, paint); + + // Inner circle + if (!position.isDismissed) { + paint.style = PaintingStyle.fill; + canvas.drawCircle(center, _kInnerRadius * position.value, paint); + } + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 6dbe942f..8511cd6a 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -29,6 +29,7 @@ export 'src/components/navigation bar/navigation_bar.dart'; export 'src/components/password/password_input.dart'; export 'src/components/progress/progress_bar.dart'; export 'src/components/progress/progress_circle.dart'; +export 'src/components/radio/radio.dart'; export 'src/components/switch/zeta_switch.dart'; export 'src/theme/color_extensions.dart'; export 'src/theme/color_scheme.dart';