From 42cf5ed3bb48ffd5d399f5a52a3cc45478e8f4d4 Mon Sep 17 00:00:00 2001 From: BirgittMajas <79840500+BirgittMajas@users.noreply.github.com> Date: Wed, 17 May 2023 13:52:35 +0300 Subject: [PATCH] [MDS-518] Create MoonSegmentedControl component --- .../storybook/stories/segmented_control.dart | 196 ++++++++ example/lib/src/storybook/storybook.dart | 3 + lib/moon_design.dart | 3 + lib/src/theme/chip/chip_sizes.dart | 4 - .../segmented_control_colors.dart | 78 ++++ .../segmented_control_properties.dart | 83 ++++ .../segmented_control_size_properties.dart | 103 ++++ .../segmented_control_sizes.dart | 53 +++ .../segmented_control_theme.dart | 70 +++ lib/src/theme/theme.dart | 11 + .../common/base_segmented_tab_bar.dart | 81 ++++ .../segmented_control/segmented_control.dart | 441 ++++++++++++++++++ 12 files changed, 1122 insertions(+), 4 deletions(-) create mode 100644 example/lib/src/storybook/stories/segmented_control.dart create mode 100644 lib/src/theme/segmented_control/segmented_control_colors.dart create mode 100644 lib/src/theme/segmented_control/segmented_control_properties.dart create mode 100644 lib/src/theme/segmented_control/segmented_control_size_properties.dart create mode 100644 lib/src/theme/segmented_control/segmented_control_sizes.dart create mode 100644 lib/src/theme/segmented_control/segmented_control_theme.dart create mode 100644 lib/src/widgets/common/base_segmented_tab_bar.dart create mode 100644 lib/src/widgets/segmented_control/segmented_control.dart diff --git a/example/lib/src/storybook/stories/segmented_control.dart b/example/lib/src/storybook/stories/segmented_control.dart new file mode 100644 index 00000000..33b6ebf0 --- /dev/null +++ b/example/lib/src/storybook/stories/segmented_control.dart @@ -0,0 +1,196 @@ +import 'package:example/src/storybook/common/color_options.dart'; +import 'package:example/src/storybook/common/widgets/text_divider.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +class SegmentedControlStory extends Story { + SegmentedControlStory() + : super( + name: "SegmentedControl", + builder: (context) { + final segmentedControlSizesKnob = context.knobs.nullable.options( + label: "segmentedControlSize", + description: "Size variants for MoonSegmentedControl.", + enabled: false, + initial: MoonSegmentedControlSize.md, + options: const [ + Option(label: "sm", value: MoonSegmentedControlSize.sm), + Option(label: "md", value: MoonSegmentedControlSize.md), + ], + ); + + final backgroundColorsKnob = context.knobs.nullable.options( + label: "backgroundColor", + description: "MoonColors variants for MoonSegmentedControl background.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final backgroundColor = colorTable(context)[backgroundColorsKnob ?? 40]; + + final selectedSegmentColorsKnob = context.knobs.nullable.options( + label: "selectedSegmentColor", + description: "MoonColors variants for selected segment.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final selectedSegmentColor = colorTable(context)[selectedSegmentColorsKnob ?? 40]; + + final textColorsKnob = context.knobs.nullable.options( + label: "textColor", + description: "MoonColors variants for default text.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final textColor = colorTable(context)[textColorsKnob ?? 40]; + + final selectedTextColorsKnob = context.knobs.nullable.options( + label: "selectedTextColor", + description: "MoonColors variants for selected segment text.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final selectedTextColor = colorTable(context)[selectedTextColorsKnob ?? 40]; + + final borderRadiusKnob = context.knobs.nullable.sliderInt( + label: "borderRadius", + description: "Border radius for MoonSegmentedControl.", + enabled: false, + initial: 12, + max: 32, + ); + + final segmentBorderRadiusKnob = context.knobs.nullable.sliderInt( + label: "segmentBorderRadius", + description: "Border radius for MoonSegmentedControl segments.", + enabled: false, + initial: 8, + max: 32, + ); + + final gapKnob = context.knobs.nullable.sliderInt( + label: "gap", + description: "Gap between MoonSegmentedControl segments.", + enabled: false, + initial: 4, + max: 16, + ); + + final isExpandedKnob = context.knobs.boolean( + label: "isExpanded", + description: "Whether MoonSegmentControl is horizontally expanded.", + ); + + final showLeadingKnob = context.knobs.boolean( + label: "leading", + description: "Show widget in MoonSegmentedControl leading slot.", + ); + + final showLabelKnob = context.knobs.boolean( + label: "label", + description: "Show widget in MoonSegmentedControl label slot.", + initial: true, + ); + + final showTrailingKnob = context.knobs.boolean( + label: "trailing", + description: "Show widget in MoonSegmentedControl trailing slot.", + ); + + final segmentStyle = SegmentStyle( + textColor: textColor, + selectedTextColor: selectedTextColor, + selectedSegmentColor: selectedSegmentColor, + segmentBorderRadius: + segmentBorderRadiusKnob != null ? BorderRadius.circular(segmentBorderRadiusKnob.toDouble()) : null, + ); + + return Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 64), + const TextDivider(text: "Default MoonSegmentedControl"), + const SizedBox(height: 32), + Column( + children: [ + MoonSegmentedControl( + isExpanded: isExpandedKnob, + gap: gapKnob?.toDouble(), + segmentedControlSize: segmentedControlSizesKnob, + backgroundColor: backgroundColor, + borderRadius: + borderRadiusKnob != null ? BorderRadius.circular(borderRadiusKnob.toDouble()) : null, + segments: [ + Segment( + leading: showLeadingKnob ? const MoonIcon(MoonIcons.frame_24) : null, + label: showLabelKnob ? const Text('Tab') : null, + trailing: showTrailingKnob ? const MoonIcon(MoonIcons.frame_24) : null, + segmentStyle: segmentStyle, + ), + Segment( + leading: showLeadingKnob ? const MoonIcon(MoonIcons.frame_24) : null, + label: showLabelKnob ? const Text('Tab') : null, + trailing: showTrailingKnob ? const MoonIcon(MoonIcons.frame_24) : null, + segmentStyle: segmentStyle, + ), + Segment( + leading: showLeadingKnob ? const MoonIcon(MoonIcons.frame_24) : null, + label: showLabelKnob ? const Text('Tab') : null, + trailing: showTrailingKnob ? const MoonIcon(MoonIcons.frame_24) : null, + segmentStyle: segmentStyle, + ), + ], + ), + const SizedBox(height: 32), + const TextDivider(text: "Icon MoonSegmentedControl"), + const SizedBox(height: 32), + MoonSegmentedControl( + isExpanded: isExpandedKnob, + gap: gapKnob?.toDouble(), + segmentedControlSize: segmentedControlSizesKnob, + backgroundColor: backgroundColor, + borderRadius: + borderRadiusKnob != null ? BorderRadius.circular(borderRadiusKnob.toDouble()) : null, + segments: [ + Segment( + trailing: const MoonIcon(MoonIcons.frame_24), + segmentStyle: segmentStyle, + ), + Segment( + trailing: const MoonIcon(MoonIcons.frame_24), + segmentStyle: segmentStyle, + ), + Segment( + trailing: const MoonIcon(MoonIcons.frame_24), + segmentStyle: segmentStyle, + ), + Segment( + trailing: const MoonIcon(MoonIcons.frame_24), + segmentStyle: segmentStyle, + ), + ], + ), + ], + ), + const SizedBox(height: 64), + ], + ), + ), + ); + }, + ); +} diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index 8eeae934..27c288d3 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -14,6 +14,7 @@ import 'package:example/src/storybook/stories/linear_progress.dart'; import 'package:example/src/storybook/stories/modal.dart'; import 'package:example/src/storybook/stories/popover.dart'; import 'package:example/src/storybook/stories/radio.dart'; +import 'package:example/src/storybook/stories/segmented_control.dart'; import 'package:example/src/storybook/stories/switch.dart'; import 'package:example/src/storybook/stories/tag.dart'; import 'package:example/src/storybook/stories/text_area.dart'; @@ -26,6 +27,7 @@ import 'package:storybook_flutter/storybook_flutter.dart'; class StorybookPage extends StatelessWidget { static bool isLargeScreen = MediaQueryData.fromWindow(WidgetsBinding.instance.window).size.width > 1000; + const StorybookPage({super.key}); static final _storyPanelFocusNode = FocusNode(); @@ -92,6 +94,7 @@ class StorybookPage extends StatelessWidget { ModalStory(), PopoverStory(), RadioStory(), + SegmentedControlStory(), SwitchStory(), TagStory(), TextAreaStory(), diff --git a/lib/moon_design.dart b/lib/moon_design.dart index 59defd96..c6ee1ef1 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -19,6 +19,7 @@ export 'package:moon_design/src/theme/popover/popover_theme.dart'; export 'package:moon_design/src/theme/progress/circular_progress/circular_progress_theme.dart'; export 'package:moon_design/src/theme/progress/linear_progress/linear_progress_theme.dart'; export 'package:moon_design/src/theme/radio/radio_theme.dart'; +export 'package:moon_design/src/theme/segmented_control/segmented_control_theme.dart'; export 'package:moon_design/src/theme/shadows.dart'; export 'package:moon_design/src/theme/sizes.dart'; export 'package:moon_design/src/theme/switch/switch_theme.dart'; @@ -50,6 +51,7 @@ export 'package:moon_design/src/widgets/chips/chip.dart'; export 'package:moon_design/src/widgets/chips/text_chip.dart'; export 'package:moon_design/src/widgets/common/animated_icon_theme.dart'; export 'package:moon_design/src/widgets/common/base_control.dart'; +export 'package:moon_design/src/widgets/common/base_segmented_tab_bar.dart'; export 'package:moon_design/src/widgets/common/effects/focus_effect.dart'; export 'package:moon_design/src/widgets/common/effects/pulse_effect.dart'; export 'package:moon_design/src/widgets/common/icons/icons.dart'; @@ -63,6 +65,7 @@ export 'package:moon_design/src/widgets/popover/popover.dart'; export 'package:moon_design/src/widgets/progress/circular_progress.dart'; export 'package:moon_design/src/widgets/progress/linear_progress.dart'; export 'package:moon_design/src/widgets/radio/radio.dart'; +export 'package:moon_design/src/widgets/segmented_control/segmented_control.dart'; export 'package:moon_design/src/widgets/switch/switch.dart'; export 'package:moon_design/src/widgets/tag/tag.dart'; export 'package:moon_design/src/widgets/text_area/text_area.dart'; diff --git a/lib/src/theme/chip/chip_sizes.dart b/lib/src/theme/chip/chip_sizes.dart index 183717c7..5db416a0 100644 --- a/lib/src/theme/chip/chip_sizes.dart +++ b/lib/src/theme/chip/chip_sizes.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; - import 'package:moon_design/src/theme/chip/chip_size_properties.dart'; @immutable @@ -23,11 +22,8 @@ class MoonChipSizes extends ThemeExtension with DiagnosticableTre @override MoonChipSizes copyWith({ - MoonChipSizeProperties? xs, MoonChipSizeProperties? sm, MoonChipSizeProperties? md, - MoonChipSizeProperties? lg, - MoonChipSizeProperties? xl, }) { return MoonChipSizes( sm: sm ?? this.sm, diff --git a/lib/src/theme/segmented_control/segmented_control_colors.dart b/lib/src/theme/segmented_control/segmented_control_colors.dart new file mode 100644 index 00000000..379c9199 --- /dev/null +++ b/lib/src/theme/segmented_control/segmented_control_colors.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/colors.dart'; + +@immutable +class MoonSegmentedControlColors extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonSegmentedControlColors( + backgroundColor: MoonColors.light.goku, + selectedSegmentColor: MoonColors.light.gohan, + textColor: MoonColors.light.bulma, + selectedTextColor: MoonColors.light.bulma, + ); + + static final dark = MoonSegmentedControlColors( + backgroundColor: MoonColors.dark.goku, + selectedSegmentColor: MoonColors.dark.gohan, + textColor: MoonColors.dark.bulma, + selectedTextColor: MoonColors.dark.bulma, + ); + + /// Background color of SegmentedControl. + final Color backgroundColor; + + /// Color of selected segment. + final Color selectedSegmentColor; + + /// Default text color of segments. + final Color textColor; + + /// Text color of selected segment. + final Color selectedTextColor; + + const MoonSegmentedControlColors({ + required this.backgroundColor, + required this.selectedSegmentColor, + required this.textColor, + required this.selectedTextColor, + }); + + @override + MoonSegmentedControlColors copyWith({ + Color? backgroundColor, + Color? selectedSegmentColor, + Color? textColor, + Color? selectedTextColor, + }) { + return MoonSegmentedControlColors( + backgroundColor: backgroundColor ?? this.backgroundColor, + selectedSegmentColor: selectedSegmentColor ?? this.selectedSegmentColor, + textColor: textColor ?? this.textColor, + selectedTextColor: selectedTextColor ?? this.selectedTextColor, + ); + } + + @override + MoonSegmentedControlColors lerp(ThemeExtension? other, double t) { + if (other is! MoonSegmentedControlColors) return this; + + return MoonSegmentedControlColors( + backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, + selectedSegmentColor: Color.lerp(selectedSegmentColor, other.selectedSegmentColor, t)!, + textColor: Color.lerp(textColor, other.textColor, t)!, + selectedTextColor: Color.lerp(selectedTextColor, other.selectedTextColor, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonSegmentedControlColors")) + ..add(ColorProperty("backgroundColor", backgroundColor)) + ..add(ColorProperty("selectedSegmentColor", selectedSegmentColor)) + ..add(ColorProperty("textColor", textColor)) + ..add(ColorProperty("selectedTextColor", selectedTextColor)); + } +} diff --git a/lib/src/theme/segmented_control/segmented_control_properties.dart b/lib/src/theme/segmented_control/segmented_control_properties.dart new file mode 100644 index 00000000..34a10916 --- /dev/null +++ b/lib/src/theme/segmented_control/segmented_control_properties.dart @@ -0,0 +1,83 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/moon_design.dart'; + +@immutable +class MoonSegmentedControlProperties extends ThemeExtension + with DiagnosticableTreeMixin { + static final properties = MoonSegmentedControlProperties( + borderRadius: MoonBorders.borders.interactiveMd, + gap: MoonSizes.sizes.x5s, + transitionDuration: const Duration(milliseconds: 200), + transitionCurve: Curves.easeInOutCubic, + padding: const EdgeInsets.all(4), + ); + + /// SegmentedControl border radius. + final BorderRadiusGeometry borderRadius; + + /// Gap between SegmentControl segments. + final double gap; + + /// SegmentedControl transition duration. + final Duration transitionDuration; + + /// SegmentedControl transition curve. + final Curve transitionCurve; + + /// SegmentedControl padding. + final EdgeInsetsGeometry padding; + + const MoonSegmentedControlProperties({ + required this.borderRadius, + required this.gap, + required this.transitionDuration, + required this.transitionCurve, + required this.padding, + }); + + @override + MoonSegmentedControlProperties copyWith({ + BorderRadiusGeometry? borderRadius, + double? gap, + Duration? transitionDuration, + Curve? transitionCurve, + EdgeInsetsGeometry? padding, + }) { + return MoonSegmentedControlProperties( + borderRadius: borderRadius ?? this.borderRadius, + gap: gap ?? this.gap, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + padding: padding ?? this.padding, + ); + } + + @override + MoonSegmentedControlProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonSegmentedControlProperties) return this; + + return MoonSegmentedControlProperties( + borderRadius: BorderRadiusGeometry.lerp(borderRadius, other.borderRadius, t)!, + gap: lerpDouble(gap, other.gap, t)!, + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + padding: EdgeInsetsGeometry.lerp(padding, other.padding, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonSegmentedControlProperties")) + ..add(DiagnosticsProperty("borderRadius", borderRadius)) + ..add(DoubleProperty("gap", gap)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)) + ..add(DiagnosticsProperty("padding", padding)); + } +} diff --git a/lib/src/theme/segmented_control/segmented_control_size_properties.dart b/lib/src/theme/segmented_control/segmented_control_size_properties.dart new file mode 100644 index 00000000..a2aaed55 --- /dev/null +++ b/lib/src/theme/segmented_control/segmented_control_size_properties.dart @@ -0,0 +1,103 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/borders.dart'; +import 'package:moon_design/src/theme/sizes.dart'; +import 'package:moon_design/src/theme/typography/text_styles.dart'; + +@immutable +class MoonSegmentedControlSizeProperties extends ThemeExtension + with DiagnosticableTreeMixin { + static final sm = MoonSegmentedControlSizeProperties( + segmentBorderRadius: MoonBorders.borders.interactiveSm, + segmentGap: MoonSizes.sizes.x5s, + height: MoonSizes.sizes.md, + iconSizeValue: MoonSizes.sizes.xs, + segmentPadding: EdgeInsets.symmetric(horizontal: MoonSizes.sizes.x3s), + textStyle: MoonTextStyles.heading.text14, + ); + + static final md = MoonSegmentedControlSizeProperties( + segmentBorderRadius: MoonBorders.borders.interactiveSm, + segmentGap: MoonSizes.sizes.x4s, + height: MoonSizes.sizes.lg, + iconSizeValue: MoonSizes.sizes.xs, + segmentPadding: EdgeInsets.symmetric(horizontal: MoonSizes.sizes.x2s), + textStyle: MoonTextStyles.heading.text14, + ); + + /// SegmentedControl segment border radius. + final BorderRadiusGeometry segmentBorderRadius; + + /// Gap between segment leading, label and trailing widgets. + final double segmentGap; + + /// SegmentedControl height. + final double height; + + /// SegmentedControl icon size value. + final double iconSizeValue; + + /// SegmentedControl segment segmentPadding. + final EdgeInsetsGeometry segmentPadding; + + /// SegmentedControl default text style. + final TextStyle textStyle; + + const MoonSegmentedControlSizeProperties({ + required this.segmentBorderRadius, + required this.segmentGap, + required this.height, + required this.iconSizeValue, + required this.segmentPadding, + required this.textStyle, + }); + + @override + MoonSegmentedControlSizeProperties copyWith({ + BorderRadiusGeometry? segmentBorderRadius, + double? segmentGap, + double? height, + double? iconSizeValue, + EdgeInsetsGeometry? segmentPadding, + TextStyle? textStyle, + }) { + return MoonSegmentedControlSizeProperties( + segmentBorderRadius: segmentBorderRadius ?? this.segmentBorderRadius, + segmentGap: segmentGap ?? this.segmentGap, + height: height ?? this.height, + iconSizeValue: iconSizeValue ?? this.iconSizeValue, + segmentPadding: segmentPadding ?? this.segmentPadding, + textStyle: textStyle ?? this.textStyle, + ); + } + + @override + MoonSegmentedControlSizeProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonSegmentedControlSizeProperties) return this; + + return MoonSegmentedControlSizeProperties( + segmentBorderRadius: BorderRadiusGeometry.lerp(segmentBorderRadius, other.segmentBorderRadius, t)!, + segmentGap: lerpDouble(segmentGap, other.segmentGap, t)!, + height: lerpDouble(height, other.height, t)!, + iconSizeValue: lerpDouble(iconSizeValue, other.iconSizeValue, t)!, + segmentPadding: EdgeInsetsGeometry.lerp(segmentPadding, other.segmentPadding, t)!, + textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonSegmentedControlSizeProperties")) + ..add(DiagnosticsProperty("segmentBorderRadius", segmentBorderRadius)) + ..add(DoubleProperty("segmentGap", segmentGap)) + ..add(DoubleProperty("height", height)) + ..add(DoubleProperty("iconSizeValue", iconSizeValue)) + ..add(DiagnosticsProperty("segmentPadding", segmentPadding)) + ..add(DiagnosticsProperty("textStyle", textStyle)); + } +} diff --git a/lib/src/theme/segmented_control/segmented_control_sizes.dart b/lib/src/theme/segmented_control/segmented_control_sizes.dart new file mode 100644 index 00000000..1f3f1bdd --- /dev/null +++ b/lib/src/theme/segmented_control/segmented_control_sizes.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/segmented_control/segmented_control_size_properties.dart'; + +@immutable +class MoonSegmentedControlSizes extends ThemeExtension with DiagnosticableTreeMixin { + static final sizes = MoonSegmentedControlSizes( + sm: MoonSegmentedControlSizeProperties.sm, + md: MoonSegmentedControlSizeProperties.md, + ); + + /// Small segmentedControl item properties. + final MoonSegmentedControlSizeProperties sm; + + /// Medium segmentedControl item properties. + final MoonSegmentedControlSizeProperties md; + + const MoonSegmentedControlSizes({ + required this.sm, + required this.md, + }); + + @override + MoonSegmentedControlSizes copyWith({ + MoonSegmentedControlSizeProperties? sm, + MoonSegmentedControlSizeProperties? md, + }) { + return MoonSegmentedControlSizes( + sm: sm ?? this.sm, + md: md ?? this.md, + ); + } + + @override + MoonSegmentedControlSizes lerp(ThemeExtension? other, double t) { + if (other is! MoonSegmentedControlSizes) return this; + + return MoonSegmentedControlSizes( + sm: sm.lerp(other.sm, t), + md: md.lerp(other.md, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonSegmentedControlSizes")) + ..add(DiagnosticsProperty("sm", sm)) + ..add(DiagnosticsProperty("md", md)); + } +} diff --git a/lib/src/theme/segmented_control/segmented_control_theme.dart b/lib/src/theme/segmented_control/segmented_control_theme.dart new file mode 100644 index 00000000..ddde44e2 --- /dev/null +++ b/lib/src/theme/segmented_control/segmented_control_theme.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/segmented_control/segmented_control_colors.dart'; +import 'package:moon_design/src/theme/segmented_control/segmented_control_properties.dart'; +import 'package:moon_design/src/theme/segmented_control/segmented_control_sizes.dart'; + +@immutable +class MoonSegmentedControlTheme extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonSegmentedControlTheme( + colors: MoonSegmentedControlColors.light, + properties: MoonSegmentedControlProperties.properties, + sizes: MoonSegmentedControlSizes.sizes, + ); + + static final dark = MoonSegmentedControlTheme( + colors: MoonSegmentedControlColors.dark, + properties: MoonSegmentedControlProperties.properties, + sizes: MoonSegmentedControlSizes.sizes, + ); + + /// SegmentedControl colors. + final MoonSegmentedControlColors colors; + + /// SegmentedControl properties. + final MoonSegmentedControlProperties properties; + + /// SegmentedControl sizes. + final MoonSegmentedControlSizes sizes; + + const MoonSegmentedControlTheme({ + required this.colors, + required this.properties, + required this.sizes, + }); + + @override + MoonSegmentedControlTheme copyWith({ + MoonSegmentedControlColors? colors, + MoonSegmentedControlProperties? properties, + MoonSegmentedControlSizes? sizes, + }) { + return MoonSegmentedControlTheme( + colors: colors ?? this.colors, + properties: properties ?? this.properties, + sizes: sizes ?? this.sizes, + ); + } + + @override + MoonSegmentedControlTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonSegmentedControlTheme) return this; + + return MoonSegmentedControlTheme( + colors: colors.lerp(other.colors, t), + properties: properties.lerp(other.properties, t), + sizes: sizes.lerp(other.sizes, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder diagnosticProperties) { + super.debugFillProperties(diagnosticProperties); + diagnosticProperties + ..add(DiagnosticsProperty("type", "MoonSegmentedControlTheme")) + ..add(DiagnosticsProperty("colors", colors)) + ..add(DiagnosticsProperty("properties", properties)) + ..add(DiagnosticsProperty("sizes", sizes)); + } +} diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index af4ed77b..2d176b1b 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -19,6 +19,7 @@ import 'package:moon_design/src/theme/popover/popover_theme.dart'; import 'package:moon_design/src/theme/progress/circular_progress/circular_progress_theme.dart'; import 'package:moon_design/src/theme/progress/linear_progress/linear_progress_theme.dart'; import 'package:moon_design/src/theme/radio/radio_theme.dart'; +import 'package:moon_design/src/theme/segmented_control/segmented_control_theme.dart'; import 'package:moon_design/src/theme/shadows.dart'; import 'package:moon_design/src/theme/sizes.dart'; import 'package:moon_design/src/theme/switch/switch_theme.dart'; @@ -50,6 +51,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { opacity: MoonOpacity.opacities, popoverTheme: MoonPopoverTheme.light, radioTheme: MoonRadioTheme.light, + segmentedControlTheme: MoonSegmentedControlTheme.light, shadows: MoonShadows.light, sizes: MoonSizes.sizes, switchTheme: MoonSwitchTheme.light, @@ -80,6 +82,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { opacity: MoonOpacity.opacities, popoverTheme: MoonPopoverTheme.dark, radioTheme: MoonRadioTheme.dark, + segmentedControlTheme: MoonSegmentedControlTheme.dark, shadows: MoonShadows.dark, sizes: MoonSizes.sizes, switchTheme: MoonSwitchTheme.dark, @@ -145,6 +148,9 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System MoonRadio widget theming. final MoonRadioTheme radioTheme; + /// Moon Design System MoonSegmentedControl widget theming. + final MoonSegmentedControlTheme segmentedControlTheme; + /// Moon Design System shadows. final MoonShadows shadows; @@ -191,6 +197,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { required this.opacity, required this.popoverTheme, required this.radioTheme, + required this.segmentedControlTheme, required this.shadows, required this.sizes, required this.switchTheme, @@ -222,6 +229,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonOpacity? opacity, MoonPopoverTheme? popoverTheme, MoonRadioTheme? radioTheme, + MoonSegmentedControlTheme? segmentedControlTheme, MoonShadows? shadows, MoonSizes? sizes, MoonSwitchTheme? switchTheme, @@ -251,6 +259,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { opacity: opacity ?? this.opacity, popoverTheme: popoverTheme ?? this.popoverTheme, radioTheme: radioTheme ?? this.radioTheme, + segmentedControlTheme: segmentedControlTheme ?? this.segmentedControlTheme, shadows: shadows ?? this.shadows, sizes: sizes ?? this.sizes, switchTheme: switchTheme ?? this.switchTheme, @@ -286,6 +295,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { opacity: opacity.lerp(other.opacity, t), popoverTheme: popoverTheme.lerp(other.popoverTheme, t), radioTheme: radioTheme.lerp(other.radioTheme, t), + segmentedControlTheme: segmentedControlTheme.lerp(other.segmentedControlTheme, t), shadows: shadows.lerp(other.shadows, t), sizes: sizes.lerp(other.sizes, t), switchTheme: switchTheme.lerp(other.switchTheme, t), @@ -321,6 +331,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { ..add(DiagnosticsProperty("MoonOpacity", opacity)) ..add(DiagnosticsProperty("MoonPopoverTheme", popoverTheme)) ..add(DiagnosticsProperty("MoonRadioTheme", radioTheme)) + ..add(DiagnosticsProperty("MoonSegmentedControlTheme", segmentedControlTheme)) ..add(DiagnosticsProperty("MoonShadows", shadows)) ..add(DiagnosticsProperty("MoonSizes", sizes)) ..add(DiagnosticsProperty("MoonSwitchTheme", switchTheme)) diff --git a/lib/src/widgets/common/base_segmented_tab_bar.dart b/lib/src/widgets/common/base_segmented_tab_bar.dart new file mode 100644 index 00000000..220ee2a6 --- /dev/null +++ b/lib/src/widgets/common/base_segmented_tab_bar.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +class BaseSegmentedTabBar extends StatefulWidget { + final bool isExpanded; + final int selectedIndex; + final TabController? tabController; + final ValueChanged valueChanged; + final List children; + + const BaseSegmentedTabBar({ + super.key, + required this.isExpanded, + required this.selectedIndex, + this.tabController, + required this.valueChanged, + required this.children, + }); + + @override + _BaseSegmentedTabBarState createState() => _BaseSegmentedTabBarState(); +} + +class _BaseSegmentedTabBarState extends State with SingleTickerProviderStateMixin { + late List _tabKeys; + late TabController? _controller; + + @override + void initState() { + super.initState(); + + _tabKeys = widget.children.map((Widget tab) => GlobalKey()).toList(); + _controller = widget.tabController ?? + TabController(length: widget.children.length, vsync: this, initialIndex: widget.selectedIndex); + } + + @override + void didUpdateWidget(BaseSegmentedTabBar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.children.length > _tabKeys.length) { + final int delta = widget.children.length - _tabKeys.length; + _tabKeys.addAll(List.generate(delta, (int n) => GlobalKey())); + } else if (widget.children.length < _tabKeys.length) { + _tabKeys.removeRange(widget.children.length, _tabKeys.length); + } + } + + @override + void dispose() { + _controller = null; + // We don't own the _controller Animation, so it's not disposed here. + super.dispose(); + } + + void _handleTap(int index) { + assert(index >= 0 && index < widget.children.length); + _controller!.animateTo(index); + widget.valueChanged.call(index); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + widget.children.length, + (int index) => widget.isExpanded + ? Expanded( + child: Listener( + onPointerDown: (_) => _handleTap(index), + child: widget.children[index], + ), + ) + : Listener( + onPointerDown: (_) => _handleTap(index), + child: widget.children[index], + ), + ), + ); + } +} diff --git a/lib/src/widgets/segmented_control/segmented_control.dart b/lib/src/widgets/segmented_control/segmented_control.dart new file mode 100644 index 00000000..2758a639 --- /dev/null +++ b/lib/src/widgets/segmented_control/segmented_control.dart @@ -0,0 +1,441 @@ +import 'package:flutter/material.dart'; + +import 'package:moon_design/moon_design.dart'; +import 'package:moon_design/src/theme/segmented_control/segmented_control_size_properties.dart'; + +enum MoonSegmentedControlSize { + sm, + md, +} + +typedef MoonCustomSegmentBuilder = Widget Function(BuildContext context, bool isSelected); + +class MoonSegmentedControl extends StatefulWidget { + /// Whether MoonSegmentedControl is expanded and takes up all available space horizontally. + final bool isExpanded; + + /// The border radius of MoonSegmentedControl. + final BorderRadiusGeometry? borderRadius; + + /// The background color of MoonSegmentedControl. + final Color? backgroundColor; + + /// The gap between MoonSegmentedControl segments. + final double? gap; + + /// The height of the MoonSegmentedControl. + final double? height; + + /// The width of the MoonSegmentedControl. + final double? width; + + /// MoonSegmentedControl transition duration. + final Duration? transitionDuration; + + /// MoonSegmentedControl transition curve. + final Curve? transitionCurve; + + /// The padding of the MoonSegmentedControl. + final EdgeInsetsGeometry? padding; + + /// The index of initially selected segment. + final int selectedIndex; + + /// The size of the MoonSegmentedControl. + final MoonSegmentedControlSize? segmentedControlSize; + + /// The shape decoration of MoonSegmentedControl. + /// + /// If [shapeDecoration] is used, then it overrides the [backgroundColor] and [borderRadius] of MoonSegmentedControl. + final ShapeDecoration? shapeDecoration; + + /// Controller of MoonSegmentedControl selection and animation state. + final TabController? tabController; + + /// Returns current selected segment index. + final ValueChanged? onSegmentChanged; + + /// The children of MoonSegmentedControl. At least one child is required. + final List? segments; + + /// The custom children of MoonSegmentedControl. At least one child is required. + final List? customSegments; + + /// MDS SegmentedControl widget. + const MoonSegmentedControl({ + super.key, + this.isExpanded = false, + this.borderRadius, + this.backgroundColor, + this.gap, + this.height, + this.width, + this.transitionDuration, + this.transitionCurve, + this.padding, + this.selectedIndex = 0, + this.segmentedControlSize, + this.shapeDecoration, + this.tabController, + this.onSegmentChanged, + required this.segments, + }) : assert(height == null || height > 0), + assert(segments != null && segments.length > 0), + customSegments = null; + + /// MDS custom SegmentedControl widget. + const MoonSegmentedControl.custom({ + super.key, + this.isExpanded = false, + this.borderRadius, + this.backgroundColor, + this.gap, + this.height, + this.width, + this.transitionDuration, + this.transitionCurve, + this.padding, + this.selectedIndex = 0, + this.segmentedControlSize, + this.shapeDecoration, + this.tabController, + this.onSegmentChanged, + required this.customSegments, + }) : assert(height == null || height > 0), + assert(customSegments != null && customSegments.length > 0), + segments = null; + + @override + State createState() => _MoonSegmentedControlState(); +} + +class _MoonSegmentedControlState extends State { + late bool _hasDefaultSegments; + late bool _hasShapeDecoration; + late int _selectedIndex; + + @override + void initState() { + super.initState(); + + _hasDefaultSegments = widget.segments != null; + _hasShapeDecoration = widget.shapeDecoration != null; + _selectedIndex = widget.selectedIndex; + + _updateSegmentsSelectedStatus(); + } + + void _updateSegmentsSelectedStatus() { + if (_hasDefaultSegments) { + widget.segments?.asMap().forEach((int index, Segment segment) { + segment.isSelected?.call(index == _selectedIndex); + }); + } else { + widget.customSegments?.asMap().forEach((int index, Widget Function(BuildContext, bool) customSegment) { + customSegment.call(context, index == _selectedIndex); + }); + } + } + + MoonSegmentedControlSizeProperties _getMoonSegmentedControlSize( + BuildContext context, + MoonSegmentedControlSize? segmentedControlSize, + ) { + switch (segmentedControlSize) { + case MoonSegmentedControlSize.sm: + return context.moonTheme?.segmentedControlTheme.sizes.sm ?? MoonSegmentedControlSizeProperties.sm; + case MoonSegmentedControlSize.md: + return context.moonTheme?.segmentedControlTheme.sizes.md ?? MoonSegmentedControlSizeProperties.md; + default: + return context.moonTheme?.segmentedControlTheme.sizes.md ?? MoonSegmentedControlSizeProperties.md; + } + } + + @override + Widget build(BuildContext context) { + final MoonSegmentedControlSizeProperties effectiveMoonSegmentControlSize = + _getMoonSegmentedControlSize(context, widget.segmentedControlSize); + + final BorderRadiusGeometry effectiveBorderRadius = widget.borderRadius ?? + context.moonTheme?.segmentedControlTheme.properties.borderRadius ?? + MoonBorders.borders.interactiveMd; + + final Color effectiveBackgroundColor = widget.shapeDecoration?.color ?? + widget.backgroundColor ?? + context.moonTheme?.segmentedControlTheme.colors.backgroundColor ?? + MoonColors.light.goku; + + final double effectiveHeight = widget.height ?? effectiveMoonSegmentControlSize.height; + + final double effectiveGap = + widget.gap ?? context.moonTheme?.segmentedControlTheme.properties.gap ?? MoonSizes.sizes.x5s; + + final EdgeInsetsGeometry effectivePadding = + widget.padding ?? context.moonTheme?.segmentedControlTheme.properties.padding ?? const EdgeInsets.all(4); + + final EdgeInsets resolvedContentPadding = effectivePadding.resolve(Directionality.of(context)); + + final Duration effectiveTransitionDuration = widget.transitionDuration ?? + context.moonTheme?.segmentedControlTheme.properties.transitionDuration ?? + const Duration(milliseconds: 200); + + final Curve effectiveTransitionCurve = widget.transitionCurve ?? + context.moonTheme?.segmentedControlTheme.properties.transitionCurve ?? + Curves.easeInOutCubic; + + return AnimatedContainer( + height: effectiveHeight, + width: widget.width, + padding: resolvedContentPadding, + duration: effectiveTransitionDuration, + curve: effectiveTransitionCurve, + constraints: BoxConstraints(minWidth: _hasShapeDecoration ? 0 : effectiveHeight), + decoration: widget.shapeDecoration ?? + BoxDecoration( + color: effectiveBackgroundColor, + borderRadius: effectiveBorderRadius.smoothBorderRadius(context), + ), + child: BaseSegmentedTabBar( + selectedIndex: widget.selectedIndex, + tabController: widget.tabController, + isExpanded: widget.isExpanded, + children: _hasDefaultSegments + ? List.generate( + widget.segments!.length, + (int index) { + return Padding( + padding: EdgeInsetsDirectional.only(start: index == 0 ? 0 : effectiveGap), + child: _SegmentBuilder( + transitionDuration: effectiveTransitionDuration, + transitionCurve: effectiveTransitionCurve, + isSelected: index == _selectedIndex, + backgroundColor: effectiveBackgroundColor, + moonSegmentedControlSizeProperties: effectiveMoonSegmentControlSize, + segment: widget.segments![index], + ), + ); + }, + ) + : List.generate( + widget.customSegments!.length, + (int index) { + return Padding( + padding: EdgeInsetsDirectional.only(start: index == 0 ? 0 : effectiveGap), + child: widget.customSegments![index](context, index == _selectedIndex), + ); + }, + ), + valueChanged: (int newIndex) { + widget.onSegmentChanged?.call(newIndex); + _updateSegmentsSelectedStatus(); + + setState(() => _selectedIndex = newIndex); + }, + ), + ); + } +} + +class _SegmentBuilder extends StatelessWidget { + final bool isSelected; + final Color backgroundColor; + final Duration transitionDuration; + final Curve transitionCurve; + final MoonSegmentedControlSizeProperties moonSegmentedControlSizeProperties; + final Segment segment; + + const _SegmentBuilder({ + required this.isSelected, + required this.backgroundColor, + required this.transitionDuration, + required this.transitionCurve, + required this.moonSegmentedControlSizeProperties, + required this.segment, + }); + + Color _getTextColor(BuildContext context, {required Color? textColor, required Color backgroundColor}) { + if (textColor != null) return textColor; + + final backgroundLuminance = backgroundColor.computeLuminance(); + if (backgroundLuminance > 0.5) { + return MoonColors.light.bulma; + } else { + return MoonColors.dark.bulma; + } + } + + @override + Widget build(BuildContext context) { + final SegmentStyle? segmentStyle = segment.segmentStyle; + + final BorderRadiusGeometry effectiveSegmentBorderRadius = + segmentStyle?.segmentBorderRadius ?? moonSegmentedControlSizeProperties.segmentBorderRadius; + + final Color effectiveSelectedSegmentColor = segmentStyle?.selectedSegmentColor ?? + context.moonTheme?.segmentedControlTheme.colors.selectedSegmentColor ?? + MoonColors.light.gohan; + + final double effectiveSegmentGap = segmentStyle?.segmentGap ?? moonSegmentedControlSizeProperties.segmentGap; + + final EdgeInsetsGeometry effectiveSegmentPadding = + segmentStyle?.segmentPadding ?? moonSegmentedControlSizeProperties.segmentPadding; + + final EdgeInsets resolvedDirectionalPadding = effectiveSegmentPadding.resolve(Directionality.of(context)); + + final EdgeInsetsGeometry correctedSegmentPadding = segmentStyle?.segmentPadding == null + ? EdgeInsetsDirectional.fromSTEB( + segment.leading == null && segment.label != null ? resolvedDirectionalPadding.left : 0, + resolvedDirectionalPadding.top, + segment.trailing == null && segment.label != null ? resolvedDirectionalPadding.right : 0, + resolvedDirectionalPadding.bottom, + ) + : resolvedDirectionalPadding; + + final TextStyle effectiveTextStyle = moonSegmentedControlSizeProperties.textStyle; + + return MoonBaseControl( + onLongPress: () => {}, + showScaleAnimation: false, + showFocusEffect: segment.showFocusEffect, + autofocus: segment.autoFocus, + focusNode: segment.focusNode, + isFocusable: segment.isFocusable, + semanticLabel: segment.semanticLabel, + borderRadius: effectiveSegmentBorderRadius.smoothBorderRadius(context), + cursor: isSelected ? SystemMouseCursors.basic : SystemMouseCursors.click, + builder: (context, isEnabled, isHovered, isFocused, isPressed) { + final bool isActive = isSelected || isHovered || isPressed; + + final Color effectiveTextColor = _getTextColor( + context, + textColor: isActive ? segmentStyle?.selectedTextColor : segmentStyle?.textColor, + backgroundColor: isActive ? effectiveSelectedSegmentColor : backgroundColor, + ); + + final TextStyle resolvedTextStyle = effectiveTextStyle + .merge(segmentStyle?.textStyle) + .copyWith(color: segmentStyle?.textStyle?.color ?? effectiveTextColor); + + return AnimatedContainer( + duration: transitionDuration, + curve: transitionCurve, + decoration: BoxDecoration( + color: isActive ? effectiveSelectedSegmentColor : backgroundColor, + borderRadius: effectiveSegmentBorderRadius.smoothBorderRadius(context), + ), + child: AnimatedIconTheme( + size: moonSegmentedControlSizeProperties.iconSizeValue, + duration: transitionDuration, + color: effectiveTextColor, + child: AnimatedDefaultTextStyle( + duration: transitionDuration, + style: resolvedTextStyle, + child: Center( + child: Padding( + padding: correctedSegmentPadding, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (segment.leading != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: effectiveSegmentGap), + child: segment.leading, + ), + if (segment.label != null) segment.label!, + if (segment.trailing != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: effectiveSegmentGap), + child: segment.trailing, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class Segment { + /// {@macro flutter.widgets.Focus.autofocus}. + final bool autoFocus; + + /// Whether this segment should be focusable. + final bool isFocusable; + + /// Whether this segment should show a focus effect. + final bool showFocusEffect; + + /// {@macro flutter.widgets.Focus.focusNode}. + final FocusNode? focusNode; + + /// The semantic label for the segment. + final String? semanticLabel; + + /// The styling options for the segment. + final SegmentStyle? segmentStyle; + + /// Returns value if segment is currently selected or not. + final ValueChanged? isSelected; + + /// The widget in the leading slot of the segment. + final Widget? leading; + + /// The widget in the label slot of the segment. + final Widget? label; + + /// The widget in the trailing slot of the segment. + final Widget? trailing; + + const Segment({ + this.autoFocus = false, + this.isFocusable = true, + this.showFocusEffect = true, + this.focusNode, + this.semanticLabel, + this.segmentStyle, + this.isSelected, + this.leading, + this.label, + this.trailing, + }); +} + +class SegmentStyle { + /// The border radius of segment. + final BorderRadiusGeometry? segmentBorderRadius; + + /// The color of the selected segment. + final Color? selectedSegmentColor; + + /// The text color of unselected segments. + final Color? textColor; + + /// The text color of selected segment. + final Color? selectedTextColor; + + /// The gap between the leading, label and trailing widgets of segment. + final double? segmentGap; + + /// The padding of the segment. + final EdgeInsetsGeometry? segmentPadding; + + /// The text style of the segment. + /// + /// If [TextStyle] color is used, then it overrides the [textColor] and [selectedTextColor]. + final TextStyle? textStyle; + + const SegmentStyle({ + this.segmentBorderRadius, + this.selectedSegmentColor, + this.textColor, + this.selectedTextColor, + this.segmentGap, + this.segmentPadding, + this.textStyle, + }); +}