From 6767d868c5b118a6d7106a6c801674f38a244f33 Mon Sep 17 00:00:00 2001 From: KolyaParadiuk <32860899+KolyaParadiuk@users.noreply.github.com> Date: Fri, 19 Jan 2024 14:45:46 +0200 Subject: [PATCH] feat: [MDS-924] Add breadcrumb widget (#334) Co-authored-by: Kolya Paradiuk Co-authored-by: BirgittMajas <79840500+BirgittMajas@users.noreply.github.com> --- example/assets/code_snippets/breadcrumb.md | 100 +++++ .../lib/src/storybook/routing/app_router.dart | 7 + .../routing/route_aware_stories.dart | 7 + .../lib/src/storybook/stories/breadcrumb.dart | 242 ++++++++++++ example/lib/src/storybook/storybook.dart | 2 + lib/moon_design.dart | 2 + .../theme/breadcrumb/breadcrumb_colors.dart | 58 +++ .../breadcrumb/breadcrumb_properties.dart | 88 +++++ .../theme/breadcrumb/breadcrumb_theme.dart | 70 ++++ lib/src/theme/theme.dart | 20 +- lib/src/widgets/breadcrumb/breadcrumb.dart | 347 ++++++++++++++++++ .../widgets/breadcrumb/breadcrumb_item.dart | 31 ++ test/breadcrumb_test.dart | 145 ++++++++ 13 files changed, 1115 insertions(+), 4 deletions(-) create mode 100644 example/assets/code_snippets/breadcrumb.md create mode 100644 example/lib/src/storybook/stories/breadcrumb.dart create mode 100644 lib/src/theme/breadcrumb/breadcrumb_colors.dart create mode 100644 lib/src/theme/breadcrumb/breadcrumb_properties.dart create mode 100644 lib/src/theme/breadcrumb/breadcrumb_theme.dart create mode 100644 lib/src/widgets/breadcrumb/breadcrumb.dart create mode 100644 lib/src/widgets/breadcrumb/breadcrumb_item.dart create mode 100644 test/breadcrumb_test.dart diff --git a/example/assets/code_snippets/breadcrumb.md b/example/assets/code_snippets/breadcrumb.md new file mode 100644 index 00000000..95ec0136 --- /dev/null +++ b/example/assets/code_snippets/breadcrumb.md @@ -0,0 +1,100 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; + +class BreadcrumbStory extends StatefulWidget { + const BreadcrumbStory({super.key}); + + @override + State createState() => _BreadcrumbStoryState(); +} + +class _BreadcrumbStoryState extends State { + bool _showDropdown = false; + Color? _dropdownIconColor; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // The default MoonBreadcrumb. + // Expands horizontally to the full path when the indicated show more item is tapped. + Column( + children: [ + MoonBreadcrumb( + items: List.generate( + 6, + (int index) { + return MoonBreadcrumbItem( + onTap: () {}, + label: Text('Page $index'), + ); + }, + ), + ), + // Provides an explicit method to restore the expanded breadcrumb path + // to its collapsed state, enabling external control. + // By default, the state is automatically restored during rebuild. + MoonButton( + onTap: () => setState(() => {}), + label: const Text('Reset'), + ), + ], + ), + + // MoonBreadcrumb with the MoonDropdown and a custom showMoreWidget. + MoonBreadcrumb( + divider: Icon( + Directionality.of(context) == TextDirection.ltr + ? MoonIcons.controls_chevron_right_small_16_light + : MoonIcons.controls_chevron_left_small_16_light, + ), + showMoreWidget: MoonDropdown( + show: _showDropdown, + onTapOutside: () => setState(() { + _showDropdown = false; + _dropdownIconColor = context.moonColors!.iconSecondary; + }), + content: Column( + children: List.generate( + 3, + (int index) => MoonMenuItem( + onTap: () {}, + label: Text('Page ${index + 1}'), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: MouseRegion( + onHover: (PointerHoverEvent _) { + setState(() => _dropdownIconColor = context.moonColors!.iconPrimary); + }, + onExit: (PointerExitEvent _) { + if (!_showDropdown) setState(() => _dropdownIconColor = context.moonColors!.iconSecondary); + }, + child: MoonButton.icon( + buttonSize: MoonButtonSize.xs, + hoverEffectColor: Colors.transparent, + iconColor: _dropdownIconColor ?? context.moonColors!.iconSecondary, + onTap: () => setState(() => _showDropdown = !_showDropdown), + icon: const Icon(MoonIcons.generic_burger_regular_16_light), + ), + ), + ), + ), + items: List.generate( + 6, + (int index) { + return MoonBreadcrumbItem( + onTap: () {}, + label: Text('Page $index'), + ); + }, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/example/lib/src/storybook/routing/app_router.dart b/example/lib/src/storybook/routing/app_router.dart index 84e1441e..59c8321b 100644 --- a/example/lib/src/storybook/routing/app_router.dart +++ b/example/lib/src/storybook/routing/app_router.dart @@ -4,6 +4,7 @@ import 'package:example/src/storybook/stories/alert.dart'; import 'package:example/src/storybook/stories/auth_code.dart'; import 'package:example/src/storybook/stories/avatar.dart'; import 'package:example/src/storybook/stories/bottom_sheet.dart'; +import 'package:example/src/storybook/stories/breadcrumb.dart'; import 'package:example/src/storybook/stories/button.dart'; import 'package:example/src/storybook/stories/carousel.dart'; import 'package:example/src/storybook/stories/checkbox.dart'; @@ -100,6 +101,12 @@ GoRouter router = GoRouter( child: BottomSheetStory(), ), ), + GoRoute( + path: BreadcrumbStory.path, + pageBuilder: (BuildContext context, GoRouterState state) => const NoTransitionPage( + child: BreadcrumbStory(), + ), + ), GoRoute( path: ButtonStory.path, pageBuilder: (BuildContext context, GoRouterState state) => const NoTransitionPage( diff --git a/example/lib/src/storybook/routing/route_aware_stories.dart b/example/lib/src/storybook/routing/route_aware_stories.dart index 7acce07f..0903c017 100644 --- a/example/lib/src/storybook/routing/route_aware_stories.dart +++ b/example/lib/src/storybook/routing/route_aware_stories.dart @@ -4,6 +4,7 @@ import 'package:example/src/storybook/stories/alert.dart'; import 'package:example/src/storybook/stories/auth_code.dart'; import 'package:example/src/storybook/stories/avatar.dart'; import 'package:example/src/storybook/stories/bottom_sheet.dart'; +import 'package:example/src/storybook/stories/breadcrumb.dart'; import 'package:example/src/storybook/stories/button.dart'; import 'package:example/src/storybook/stories/carousel.dart'; import 'package:example/src/storybook/stories/checkbox.dart'; @@ -66,6 +67,12 @@ final List routeAwareStories = [ router: router, codeString: fetchAsset('bottom_sheet.md'), ), + Story.asRoute( + name: 'Breadcrumb', + routePath: BreadcrumbStory.path, + router: router, + codeString: fetchAsset('breadcrumb.md'), + ), Story.asRoute( name: 'Button', routePath: ButtonStory.path, diff --git a/example/lib/src/storybook/stories/breadcrumb.dart b/example/lib/src/storybook/stories/breadcrumb.dart new file mode 100644 index 00000000..61911bb2 --- /dev/null +++ b/example/lib/src/storybook/stories/breadcrumb.dart @@ -0,0 +1,242 @@ +import 'package:example/src/storybook/common/color_options.dart'; +import 'package:example/src/storybook/common/widgets/text_divider.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +class BreadcrumbStory extends StatefulWidget { + static const path = '/breadcrumb'; + + const BreadcrumbStory({super.key}); + + @override + State createState() => _BreadcrumbStoryState(); +} + +class _BreadcrumbStoryState extends State { + bool _showDropdown = false; + Color? _dropdownIconColor; + + @override + Widget build(BuildContext context) { + final itemColorKnob = context.knobs.nullable.options( + label: "Item color", + description: "MoonColors variants for the MoonBreadcrumb's item.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final itemColor = colorTable(context)[itemColorKnob ?? 40]; + + final currentItemColorKnob = context.knobs.nullable.options( + label: "Current item color", + description: "MoonColors variants for the current MoonBreadcrumb's item.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final currentItemColor = colorTable(context)[currentItemColorKnob ?? 40]; + + final hoverEffectColorKnob = context.knobs.nullable.options( + label: "hoverEffectColor", + description: "MoonColors variants for the MoonBreadcrumb's item on hover.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final hoverEffectColor = colorTable(context)[hoverEffectColorKnob ?? 40]; + + final dividerColorKnob = context.knobs.nullable.options( + label: "dividerColor", + description: "MoonColors variants for the MoonBreadcrumb's divider.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final dividerColor = colorTable(context)[dividerColorKnob ?? 40]; + + final itemCountKnob = context.knobs.nullable.sliderInt( + label: "Item count", + description: "Total count of items for the MoonBreadcrumb.", + enabled: false, + initial: 7, + max: 12, + ); + + final visibleItemCountKnob = context.knobs.nullable.sliderInt( + label: "visibleItemCount", + description: "Count of items to display for the MoonBreadcrumb.", + enabled: false, + initial: 3, + max: 12, + ); + + final gapKnob = context.knobs.nullable.sliderInt( + label: "gap", + description: "Gap between the MoonBreadcrumb's items.", + enabled: false, + initial: 8, + max: 16, + ); + + final showLeadingKnob = context.knobs.boolean( + label: "leading", + description: "Show widget in the MoonBreadcrumb item's leading slot.", + ); + + final showTrailingKnob = context.knobs.boolean( + label: "trailing", + description: "Show widget in the MoonBreadcrumb item's trailing slot.", + ); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const TextDivider( + text: "MoonBreadcrumb", + paddingTop: 0, + ), + Column( + children: [ + MoonBreadcrumb( + visibleItemCount: visibleItemCountKnob ?? 3, + gap: gapKnob?.toDouble(), + padding: const EdgeInsets.symmetric(horizontal: 16), + hoverEffectColor: hoverEffectColor, + dividerColor: dividerColor, + itemTextStyle: TextStyle(color: itemColor), + currentItemTextStyle: TextStyle(color: currentItemColor), + items: List.generate( + itemCountKnob ?? 7, + (int index) { + final bool isHomePage = index == 0; + + return MoonBreadcrumbItem( + onTap: () => MoonToast.show( + context, + displayDuration: const Duration(seconds: 1), + label: Text(isHomePage ? 'Home Page' : 'Page $index'), + ), + leading: + showLeadingKnob && isHomePage ? const Icon(MoonIcons.generic_home_16_light, size: 16) : null, + label: Text(isHomePage ? 'Home' : 'Page $index'), + trailing: + showTrailingKnob && isHomePage ? const Icon(MoonIcons.generic_home_16_light, size: 16) : null, + ); + }, + ), + ), + const SizedBox(height: 16), + MoonButton( + backgroundColor: context.moonColors!.piccolo, + onTap: () => setState(() => {}), + label: Text( + 'Reset', + style: TextStyle(color: context.moonColors!.goten), + ), + ), + ], + ), + const TextDivider(text: "Custom MoonBreadcrumb with MoonDropdown"), + StatefulBuilder( + builder: (context, setState) { + return MoonBreadcrumb( + visibleItemCount: visibleItemCountKnob ?? 3, + gap: gapKnob?.toDouble(), + padding: const EdgeInsets.symmetric(horizontal: 16), + hoverEffectColor: hoverEffectColor, + dividerColor: dividerColor, + itemTextStyle: TextStyle(color: itemColor), + currentItemTextStyle: TextStyle(color: currentItemColor), + itemDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + divider: Icon( + Directionality.of(context) == TextDirection.ltr + ? MoonIcons.controls_chevron_right_small_16_light + : MoonIcons.controls_chevron_left_small_16_light, + ), + showMoreWidget: MoonDropdown( + show: _showDropdown, + onTapOutside: () => setState(() { + _showDropdown = false; + _dropdownIconColor = context.moonColors!.iconSecondary; + }), + content: Column( + children: List.generate( + 4, + (int index) => MoonMenuItem( + width: 120, + onTap: () => MoonToast.show( + context, + displayDuration: const Duration(seconds: 1), + label: Text('Page ${index + 1}'), + ), + label: Text('Page ${index + 1}'), + ), + ), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: gapKnob?.toDouble() ?? 8), + child: MouseRegion( + onHover: (PointerHoverEvent event) { + setState(() => _dropdownIconColor = hoverEffectColor ?? context.moonColors!.iconPrimary); + }, + onExit: (PointerExitEvent event) { + if (!_showDropdown) { + setState(() => _dropdownIconColor = itemColor ?? context.moonColors!.iconSecondary); + } + }, + child: MoonButton.icon( + buttonSize: MoonButtonSize.xs, + hoverEffectColor: Colors.transparent, + iconColor: _dropdownIconColor ?? context.moonColors!.iconSecondary, + icon: const Icon(MoonIcons.generic_burger_regular_16_light), + onTap: () => setState(() => _showDropdown = !_showDropdown), + ), + ), + ), + ), + items: List.generate( + itemCountKnob ?? 7, + (int index) { + final bool isHomePage = index == 0; + + return MoonBreadcrumbItem( + onTap: () => MoonToast.show( + context, + displayDuration: const Duration(seconds: 1), + label: Text(isHomePage ? 'Home Page' : 'Page $index'), + ), + leading: showLeadingKnob && isHomePage + ? const Icon( + MoonIcons.generic_home_16_light, + size: 16, + ) + : null, + label: Text(isHomePage ? 'Home' : 'Page $index'), + trailing: showTrailingKnob && isHomePage + ? const Icon( + MoonIcons.generic_home_16_light, + size: 16, + ) + : null, + ); + }, + ), + ); + }, + ), + ], + ); + } +} diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index b2164a99..06ec1ea1 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -32,6 +32,7 @@ class StorybookPage extends StatelessWidget { extensions: >[ MoonTheme( tokens: MoonTokens.light.copyWith( + colors: mdsLightColors, typography: MoonTypography.typography.copyWith( heading: MoonTypography.typography.heading.apply(fontFamily: "DMSans"), body: MoonTypography.typography.body.apply(fontFamily: "DMSans"), @@ -45,6 +46,7 @@ class StorybookPage extends StatelessWidget { extensions: >[ MoonTheme( tokens: MoonTokens.dark.copyWith( + colors: mdsDarkColors, typography: MoonTypography.typography.copyWith( heading: MoonTypography.typography.heading.apply(fontFamily: "DMSans"), body: MoonTypography.typography.body.apply(fontFamily: "DMSans"), diff --git a/lib/moon_design.dart b/lib/moon_design.dart index a10c779d..132bfbc5 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -52,6 +52,8 @@ export 'package:moon_design/src/widgets/authcode/authcode.dart'; export 'package:moon_design/src/widgets/avatar/avatar.dart'; export 'package:moon_design/src/widgets/bottom_sheet/bottom_sheet.dart'; export 'package:moon_design/src/widgets/bottom_sheet/modal_bottom_sheet.dart'; +export 'package:moon_design/src/widgets/breadcrumb/breadcrumb.dart'; +export 'package:moon_design/src/widgets/breadcrumb/breadcrumb_item.dart'; export 'package:moon_design/src/widgets/buttons/button.dart'; export 'package:moon_design/src/widgets/buttons/filled_button.dart'; export 'package:moon_design/src/widgets/buttons/outlined_button.dart'; diff --git a/lib/src/theme/breadcrumb/breadcrumb_colors.dart b/lib/src/theme/breadcrumb/breadcrumb_colors.dart new file mode 100644 index 00000000..826b3c14 --- /dev/null +++ b/lib/src/theme/breadcrumb/breadcrumb_colors.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/src/utils/color_premul_lerp.dart'; + +@immutable +class MoonBreadcrumbColors extends ThemeExtension with DiagnosticableTreeMixin { + /// The icon and text color of the MoonBreadcrumb's item. + final Color itemColor; + + /// The icon and text color of the MoonBreadcrumb's current item. + final Color? currentItemColor; + + /// The icon and text color of the MoonBreadcrumb's item on hover. + final Color? hoverEffectColor; + + const MoonBreadcrumbColors({ + required this.itemColor, + required this.currentItemColor, + required this.hoverEffectColor, + }); + + @override + MoonBreadcrumbColors copyWith({ + Color? itemColor, + Color? currentItemColor, + Color? hoverEffectColor, + }) { + return MoonBreadcrumbColors( + itemColor: itemColor ?? this.itemColor, + currentItemColor: currentItemColor ?? this.currentItemColor, + hoverEffectColor: hoverEffectColor ?? this.hoverEffectColor, + ); + } + + @override + MoonBreadcrumbColors lerp( + ThemeExtension? other, + double t, + ) { + if (other is! MoonBreadcrumbColors) return this; + + return MoonBreadcrumbColors( + itemColor: colorPremulLerp(itemColor, other.itemColor, t)!, + currentItemColor: colorPremulLerp(currentItemColor, other.currentItemColor, t), + hoverEffectColor: colorPremulLerp(hoverEffectColor, other.hoverEffectColor, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonBreadcrumbColors")) + ..add(ColorProperty("itemColor", itemColor)) + ..add(ColorProperty("currentItemColor", currentItemColor)) + ..add(ColorProperty("hoverEffectColor", hoverEffectColor)); + } +} diff --git a/lib/src/theme/breadcrumb/breadcrumb_properties.dart b/lib/src/theme/breadcrumb/breadcrumb_properties.dart new file mode 100644 index 00000000..e392d7d9 --- /dev/null +++ b/lib/src/theme/breadcrumb/breadcrumb_properties.dart @@ -0,0 +1,88 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +@immutable +class MoonBreadcrumbProperties extends ThemeExtension with DiagnosticableTreeMixin { + /// The gap between the divider and the MoonBreadcrumb's item. + final double gap; + + /// The gap between the MoonBreadcrumb item's leading, label and trailing widgets. + final double itemGap; + + /// The duration of the MoonBreadcrumb's transition animation. + final Duration transitionDuration; + + /// The curve of the MoonBreadcrumb's transition animation. + final Curve transitionCurve; + + /// The text style of the MoonBreadcrumb's item. + final TextStyle itemTextStyle; + + /// The text style of the current MoonBreadcrumb's item. + final TextStyle currentItemTextStyle; + + /// The text style of the MoonBreadcrumb's show more item. + final TextStyle showMoreItemTextStyle; + + const MoonBreadcrumbProperties({ + required this.gap, + required this.itemGap, + required this.transitionDuration, + required this.transitionCurve, + required this.itemTextStyle, + required this.currentItemTextStyle, + required this.showMoreItemTextStyle, + }); + + @override + MoonBreadcrumbProperties copyWith({ + double? gap, + double? itemGap, + Duration? transitionDuration, + Curve? transitionCurve, + TextStyle? itemTextStyle, + TextStyle? currentItemTextStyle, + TextStyle? showMoreItemTextStyle, + }) { + return MoonBreadcrumbProperties( + gap: gap ?? this.gap, + itemGap: itemGap ?? this.itemGap, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + itemTextStyle: itemTextStyle ?? this.itemTextStyle, + currentItemTextStyle: currentItemTextStyle ?? this.currentItemTextStyle, + showMoreItemTextStyle: showMoreItemTextStyle ?? this.showMoreItemTextStyle, + ); + } + + @override + MoonBreadcrumbProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonBreadcrumbProperties) return this; + + return MoonBreadcrumbProperties( + gap: lerpDouble(gap, other.gap, t)!, + itemGap: lerpDouble(itemGap, other.itemGap, t)!, + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + itemTextStyle: TextStyle.lerp(itemTextStyle, other.itemTextStyle, t)!, + currentItemTextStyle: TextStyle.lerp(currentItemTextStyle, other.currentItemTextStyle, t)!, + showMoreItemTextStyle: TextStyle.lerp(showMoreItemTextStyle, other.showMoreItemTextStyle, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonBreadcrumbProperties")) + ..add(DoubleProperty("gap", gap)) + ..add(DoubleProperty("itemGap", itemGap)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)) + ..add(DiagnosticsProperty("itemTextStyle", itemTextStyle)) + ..add(DiagnosticsProperty("currentItemTextStyle", currentItemTextStyle)) + ..add(DiagnosticsProperty("showMoreItemTextStyle", showMoreItemTextStyle)); + } +} diff --git a/lib/src/theme/breadcrumb/breadcrumb_theme.dart b/lib/src/theme/breadcrumb/breadcrumb_theme.dart new file mode 100644 index 00000000..f09abd01 --- /dev/null +++ b/lib/src/theme/breadcrumb/breadcrumb_theme.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:moon_design/src/theme/breadcrumb/breadcrumb_colors.dart'; +import 'package:moon_design/src/theme/breadcrumb/breadcrumb_properties.dart'; + +@immutable +class MoonBreadcrumbTheme extends ThemeExtension with DiagnosticableTreeMixin { + /// The tokens of the MDS. + final MoonTokens tokens; + + /// The colors of the MoonBreadcrumb. + final MoonBreadcrumbColors colors; + + /// The properties of the MoonBreadcrumb. + final MoonBreadcrumbProperties properties; + + MoonBreadcrumbTheme({ + required this.tokens, + MoonBreadcrumbColors? colors, + MoonBreadcrumbProperties? properties, + }) : colors = colors ?? + MoonBreadcrumbColors( + itemColor: tokens.colors.textSecondary, + currentItemColor: tokens.colors.textPrimary, + hoverEffectColor: tokens.colors.textPrimary, + ), + properties = properties ?? + MoonBreadcrumbProperties( + gap: tokens.sizes.x4s, + itemGap: tokens.sizes.x6s, + transitionDuration: tokens.transitions.defaultTransitionDuration, + transitionCurve: tokens.transitions.defaultTransitionCurve, + itemTextStyle: tokens.typography.body.textDefault, + currentItemTextStyle: tokens.typography.body.textDefault, + showMoreItemTextStyle: tokens.typography.caption.textDefault, + ); + + @override + MoonBreadcrumbTheme copyWith({ + MoonTokens? tokens, + MoonBreadcrumbColors? colors, + MoonBreadcrumbProperties? properties, + }) { + return MoonBreadcrumbTheme( + tokens: tokens ?? this.tokens, + colors: colors ?? this.colors, + properties: properties ?? this.properties, + ); + } + + @override + MoonBreadcrumbTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonBreadcrumbTheme) return this; + + return MoonBreadcrumbTheme( + tokens: tokens.lerp(other.tokens, t), + colors: colors.lerp(other.colors, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder diagnosticProperties) { + super.debugFillProperties(diagnosticProperties); + diagnosticProperties + ..add(DiagnosticsProperty("type", "MoonBreadcrumbTheme")) + ..add(DiagnosticsProperty("tokens", tokens)) + ..add(DiagnosticsProperty("colors", colors)); + } +} diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index b412689b..1b2f5be4 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -1,10 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; + import 'package:moon_design/src/theme/accordion/accordion_theme.dart'; import 'package:moon_design/src/theme/alert/alert_theme.dart'; import 'package:moon_design/src/theme/authcode/authcode_theme.dart'; import 'package:moon_design/src/theme/avatar/avatar_theme.dart'; import 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_theme.dart'; +import 'package:moon_design/src/theme/breadcrumb/breadcrumb_theme.dart'; import 'package:moon_design/src/theme/button/button_theme.dart'; import 'package:moon_design/src/theme/carousel/carousel_theme.dart'; import 'package:moon_design/src/theme/checkbox/checkbox_theme.dart'; @@ -43,7 +45,7 @@ import 'package:moon_tokens/moon_tokens.dart'; @immutable class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { - ///Moon Design System tokens. + /// Moon Design System tokens. final MoonTokens tokens; /// Moon Design System MoonAccordion widget theming. @@ -58,10 +60,13 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System MoonAvatar widget theming. final MoonAvatarTheme avatarTheme; - /// Moon Design System MoonButton widgets theming. + /// Moon Design System MoonBottomSheet widget theming. final MoonBottomSheetTheme bottomSheetTheme; - /// Moon Design System MoonButton widgets theming. + /// Moon Design System MoonBreadcrumb widget theming. + final MoonBreadcrumbTheme breadcrumbTheme; + + /// Moon Design System MoonButton widget theming. final MoonButtonTheme buttonTheme; /// Moon Design System MoonCarousel widget theming. @@ -70,7 +75,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System MoonCheckbox widget theming. final MoonCheckboxTheme checkboxTheme; - /// Moon Design System MoonChip widgets theming. + /// Moon Design System MoonChip widget theming. final MoonChipTheme chipTheme; /// Moon Design System MoonCircularLoader widget theming. @@ -149,6 +154,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonAuthCodeTheme? authCodeTheme, MoonAvatarTheme? avatarTheme, MoonBottomSheetTheme? bottomSheetTheme, + MoonBreadcrumbTheme? breadcrumbTheme, MoonButtonTheme? buttonTheme, MoonCarouselTheme? carouselTheme, MoonCheckboxTheme? checkboxTheme, @@ -181,6 +187,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme = authCodeTheme ?? MoonAuthCodeTheme(tokens: tokens), avatarTheme = avatarTheme ?? MoonAvatarTheme(tokens: tokens), bottomSheetTheme = bottomSheetTheme ?? MoonBottomSheetTheme(tokens: tokens), + breadcrumbTheme = breadcrumbTheme ?? MoonBreadcrumbTheme(tokens: tokens), buttonTheme = buttonTheme ?? MoonButtonTheme(tokens: tokens), carouselTheme = carouselTheme ?? MoonCarouselTheme(tokens: tokens), checkboxTheme = checkboxTheme ?? MoonCheckboxTheme(tokens: tokens), @@ -217,6 +224,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonAuthCodeTheme? authCodeTheme, MoonAvatarTheme? avatarTheme, MoonBottomSheetTheme? bottomSheetTheme, + MoonBreadcrumbTheme? breadcrumbTheme, MoonButtonTheme? buttonTheme, MoonCarouselTheme? carouselTheme, MoonCheckboxTheme? checkboxTheme, @@ -252,6 +260,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme: authCodeTheme ?? this.authCodeTheme, avatarTheme: avatarTheme ?? this.avatarTheme, bottomSheetTheme: bottomSheetTheme ?? this.bottomSheetTheme, + breadcrumbTheme: breadcrumbTheme ?? this.breadcrumbTheme, buttonTheme: buttonTheme ?? this.buttonTheme, carouselTheme: carouselTheme ?? this.carouselTheme, checkboxTheme: checkboxTheme ?? this.checkboxTheme, @@ -293,6 +302,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme: authCodeTheme.lerp(other.authCodeTheme, t), avatarTheme: avatarTheme.lerp(other.avatarTheme, t), bottomSheetTheme: bottomSheetTheme.lerp(other.bottomSheetTheme, t), + breadcrumbTheme: breadcrumbTheme.lerp(other.breadcrumbTheme, t), buttonTheme: buttonTheme.lerp(other.buttonTheme, t), carouselTheme: carouselTheme.lerp(other.carouselTheme, t), checkboxTheme: checkboxTheme.lerp(other.checkboxTheme, t), @@ -333,6 +343,8 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { ..add(DiagnosticsProperty("MoonAlertTheme", alertTheme)) ..add(DiagnosticsProperty("MoonAuthCodeTheme", authCodeTheme)) ..add(DiagnosticsProperty("MoonAvatarTheme", avatarTheme)) + ..add(DiagnosticsProperty("MoonBottomSheetTheme", bottomSheetTheme)) + ..add(DiagnosticsProperty("MoonBreadcrumbTheme", breadcrumbTheme)) ..add(DiagnosticsProperty("MoonButtonTheme", buttonTheme)) ..add(DiagnosticsProperty("MoonCarouselTheme", carouselTheme)) ..add(DiagnosticsProperty("MoonCheckboxTheme", checkboxTheme)) diff --git a/lib/src/widgets/breadcrumb/breadcrumb.dart b/lib/src/widgets/breadcrumb/breadcrumb.dart new file mode 100644 index 00000000..5767b48e --- /dev/null +++ b/lib/src/widgets/breadcrumb/breadcrumb.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; +import 'package:moon_design/src/theme/breadcrumb/breadcrumb_theme.dart'; +import 'package:moon_design/src/theme/theme.dart'; +import 'package:moon_design/src/theme/tokens/sizes.dart'; +import 'package:moon_design/src/theme/tokens/transitions.dart'; +import 'package:moon_design/src/theme/tokens/typography/text_styles.dart'; +import 'package:moon_design/src/utils/color_tween_premul.dart'; +import 'package:moon_design/src/widgets/breadcrumb/breadcrumb_item.dart'; +import 'package:moon_design/src/widgets/common/base_control.dart'; +import 'package:moon_icons/moon_icons.dart'; +import 'package:moon_tokens/moon_tokens.dart'; + +class MoonBreadcrumb extends StatefulWidget { + /// The divider color of the breadcrumb. + final Color? dividerColor; + + /// The icon and text color of the breadcrumb's item on hover. + final Color? hoverEffectColor; + + /// The box decoration of the breadcrumb's item. + final BoxDecoration? itemDecoration; + + /// The gap between the [divider] widget and the breadcrumb's item. + final double? gap; + + /// The padding of the breadcrumb. + final EdgeInsetsGeometry? padding; + + /// The total count of the breadcrumb's [items] to display. + final int visibleItemCount; + + /// The semantic label of the breadcrumb's default show more widget. + final String? semanticLabel; + + /// The text style of the breadcrumb's item. + final TextStyle? itemTextStyle; + + /// The text style of the current breadcrumb's item. + final TextStyle? currentItemTextStyle; + + /// The breadcrumb's items to display as a sequence of steps. + final List items; + + /// The widget to display between the breadcrumb's items. + final Widget? divider; + + /// The single custom widget to replace all the breadcrumb's collapsed items with. + final Widget? showMoreWidget; + + const MoonBreadcrumb({ + super.key, + this.dividerColor, + this.hoverEffectColor, + this.itemDecoration, + this.gap, + this.padding, + this.visibleItemCount = 3, + this.semanticLabel, + this.itemTextStyle, + this.currentItemTextStyle, + required this.items, + this.divider, + this.showMoreWidget, + }); + + @override + State createState() => _MoonBreadcrumbState(); +} + +class _MoonBreadcrumbState extends State { + bool showFullPath = false; + + List _buildItems() { + final MoonBreadcrumbTheme? theme = context.moonTheme?.breadcrumbTheme; + + final double effectiveGap = widget.gap ?? theme?.properties.gap ?? MoonSizes.sizes.x4s; + + final Color effectiveItemTextColor = + widget.itemTextStyle?.color ?? theme?.colors.itemColor ?? MoonColors.light.textSecondary; + + final Color effectiveCurrentItemTextColor = + widget.currentItemTextStyle?.color ?? theme?.colors.currentItemColor ?? MoonColors.light.textPrimary; + + final Color effectiveHoverEffectColor = + widget.hoverEffectColor ?? theme?.colors.hoverEffectColor ?? MoonColors.light.textPrimary; + + final TextStyle effectiveItemTextStyle = + widget.itemTextStyle ?? theme?.properties.itemTextStyle ?? MoonTextStyles.body.textDefault; + + final TextStyle effectiveCurrentItemTextStyle = + widget.currentItemTextStyle ?? theme?.properties.currentItemTextStyle ?? MoonTextStyles.body.textDefault; + + final TextStyle effectiveShowMoreItemTextStyle = + theme?.properties.showMoreItemTextStyle ?? MoonTextStyles.caption.textDefault; + + final Duration effectiveTransitionDuration = + theme?.properties.transitionDuration ?? MoonTransitions.transitions.defaultTransitionDuration; + + final Curve effectiveTransitionCurve = + theme?.properties.transitionCurve ?? MoonTransitions.transitions.defaultTransitionCurve; + + final int resolvedItemCountToShow = showFullPath ? widget.items.length : widget.visibleItemCount; + + final List visibleItemsList = _getVisibleItems(); + + final List customizedVisibleItemsList = visibleItemsList + .map( + (MoonBreadcrumbItem item) => Row( + children: [ + if (item != visibleItemsList.first) SizedBox(width: effectiveGap), + _BreadcrumbItemBuilder( + isCurrent: item == visibleItemsList.last, + itemColor: effectiveItemTextColor, + currentItemColor: effectiveCurrentItemTextColor, + hoverEffectColor: effectiveHoverEffectColor, + decoration: widget.itemDecoration, + itemTextStyle: effectiveItemTextStyle, + currentItemTextStyle: effectiveCurrentItemTextStyle, + transitionDuration: effectiveTransitionDuration, + transitionCurve: effectiveTransitionCurve, + onTap: item.onTap, + item: item, + ), + if (item != visibleItemsList.last) ...[ + SizedBox(width: effectiveGap), + _buildDivider(), + ], + ], + ), + ) + .toList(); + + if (widget.items.length > resolvedItemCountToShow && resolvedItemCountToShow > 1) { + customizedVisibleItemsList.insert( + 1, + Row( + children: [ + widget.showMoreWidget ?? + Padding( + padding: EdgeInsets.symmetric(horizontal: effectiveGap), + child: _BreadcrumbItemBuilder( + isCurrent: false, + itemColor: effectiveItemTextColor, + currentItemColor: effectiveCurrentItemTextColor, + hoverEffectColor: effectiveHoverEffectColor, + decoration: widget.itemDecoration, + itemTextStyle: effectiveItemTextStyle, + currentItemTextStyle: effectiveCurrentItemTextStyle, + transitionDuration: effectiveTransitionDuration, + transitionCurve: effectiveTransitionCurve, + onTap: () => setState(() => showFullPath = true), + item: MoonBreadcrumbItem( + semanticLabel: widget.semanticLabel, + label: SizedBox( + width: 24, + child: Text( + '...', + textAlign: TextAlign.center, + style: effectiveShowMoreItemTextStyle, + ), + ), + ), + ), + ), + _buildDivider(), + ], + ), + ); + } + + // Restores the breadcrumb's initial collapsed state during every rebuild. + showFullPath = false; + + return customizedVisibleItemsList; + } + + List _getVisibleItems() { + final int resolvedItemCountToShow = showFullPath ? widget.items.length : widget.visibleItemCount; + + final List visibleItems = resolvedItemCountToShow == 0 + ? [] + : widget.items.length > resolvedItemCountToShow + ? [ + widget.items[0], + ...List.generate(resolvedItemCountToShow - 1, (index) => widget.items.length - index) + .reversed + .map((int index) => widget.items[index - 1]), + ] + : widget.items; + + return visibleItems; + } + + Widget _buildDivider() { + final Color effectiveDividerColor = widget.dividerColor ?? + widget.itemTextStyle?.color ?? + context.moonTheme?.breadcrumbTheme.colors.itemColor ?? + MoonColors.light.iconSecondary; + + return IconTheme( + data: IconThemeData(color: effectiveDividerColor), + child: widget.divider ?? + Icon( + Directionality.of(context) == TextDirection.ltr + ? MoonIcons.arrows_right_24_light + : MoonIcons.arrows_left_24_light, + size: 24, + ), + ); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: widget.padding, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: _buildItems(), + ), + ); + } +} + +class _BreadcrumbItemBuilder extends StatefulWidget { + final bool isCurrent; + final Color itemColor; + final Color currentItemColor; + final Color hoverEffectColor; + final BoxDecoration? decoration; + final Duration transitionDuration; + final Curve transitionCurve; + final TextStyle itemTextStyle; + final TextStyle currentItemTextStyle; + final VoidCallback? onTap; + final MoonBreadcrumbItem item; + + const _BreadcrumbItemBuilder({ + required this.isCurrent, + required this.itemColor, + required this.currentItemColor, + required this.hoverEffectColor, + required this.decoration, + required this.transitionDuration, + required this.transitionCurve, + required this.itemTextStyle, + required this.currentItemTextStyle, + required this.item, + required this.onTap, + }); + + @override + State<_BreadcrumbItemBuilder> createState() => _BreadCrumbItemBuilderState(); +} + +class _BreadCrumbItemBuilderState extends State<_BreadcrumbItemBuilder> with SingleTickerProviderStateMixin { + final ColorTweenWithPremultipliedAlpha _itemColorTween = ColorTweenWithPremultipliedAlpha(); + + Animation? _itemColor; + + AnimationController? _animationController; + + void _handleActiveEffect(bool isActive) { + isActive ? _animationController?.forward() : _animationController?.reverse(); + } + + @override + void initState() { + super.initState(); + + _animationController = AnimationController(duration: widget.transitionDuration, vsync: this); + + if (widget.isCurrent) _animationController?.value = 1; + } + + @override + void dispose() { + _animationController!.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double effectiveGap = + widget.item.gap ?? context.moonTheme?.breadcrumbTheme.properties.itemGap ?? MoonSizes.sizes.x6s; + + final Color resolvedItemColor = widget.isCurrent ? widget.currentItemColor : widget.itemColor; + + final Color resolvedHoverEffectColor = widget.isCurrent ? widget.currentItemColor : widget.hoverEffectColor; + + final TextStyle resolvedTextStyle = widget.isCurrent ? widget.currentItemTextStyle : widget.itemTextStyle; + + _itemColor ??= _animationController!.drive( + _itemColorTween.chain(CurveTween(curve: widget.transitionCurve)), + ); + + _itemColorTween + ..begin = resolvedItemColor + ..end = resolvedHoverEffectColor; + + return MoonBaseControl( + semanticLabel: widget.item.semanticLabel, + backgroundColor: widget.decoration?.color, + borderRadius: widget.decoration?.borderRadius, + onTap: widget.onTap, + builder: (BuildContext context, bool isEnabled, bool isHovered, bool isFocused, bool isPressed) { + final bool isActive = isEnabled && (widget.isCurrent || isHovered || isPressed); + + _handleActiveEffect(isActive); + + return AnimatedBuilder( + animation: _animationController!, + builder: (BuildContext context, Widget? child) { + return IconTheme( + data: IconThemeData( + color: _itemColor!.value, + ), + child: DefaultTextStyle( + style: resolvedTextStyle.copyWith(color: _itemColor?.value), + child: child!, + ), + ); + }, + child: DecoratedBox( + decoration: widget.decoration ?? const BoxDecoration(), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.item.leading != null) ...[ + widget.item.leading!, + SizedBox(width: widget.item.gap ?? effectiveGap), + ], + widget.item.label, + if (widget.item.trailing != null) ...[ + SizedBox(width: widget.item.gap ?? effectiveGap), + widget.item.trailing!, + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/widgets/breadcrumb/breadcrumb_item.dart b/lib/src/widgets/breadcrumb/breadcrumb_item.dart new file mode 100644 index 00000000..dac41094 --- /dev/null +++ b/lib/src/widgets/breadcrumb/breadcrumb_item.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class MoonBreadcrumbItem { + /// The gap between the breadcrumb item's [leading], [label] and [trailing] widgets. + final double? gap; + + /// The semantic label of the breadcrumb's item. + final String? semanticLabel; + + /// Called when the breadcrumb's item is tapped or pressed. + /// If null, the breadcrumb's item is disabled. + final VoidCallback? onTap; + + /// The widget placed before the breadcrumb item's [label] widget. + final Widget? leading; + + /// The main content of the breadcrumb's item. + final Widget label; + + /// The widget placed after the breadcrumb item's [label] widget. + final Widget? trailing; + + const MoonBreadcrumbItem({ + this.gap, + this.semanticLabel, + this.onTap, + this.leading, + required this.label, + this.trailing, + }); +} diff --git a/test/breadcrumb_test.dart b/test/breadcrumb_test.dart new file mode 100644 index 00000000..8ffaad31 --- /dev/null +++ b/test/breadcrumb_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moon_design/moon_design.dart'; + +void main() { + const key = Key("breadcrumb_test"); + + testWidgets("Provided key is used", (tester) async { + await tester.pumpWidget( + const TestWidget( + widgetKey: key, + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is MoonBreadcrumb && widget.key == key, + ), + findsOneWidget, + ); + }); + + testWidgets("More items button", (tester) async { + await tester.pumpWidget( + const TestWidget( + widgetKey: key, + ), + ); + final moreButton = find.text('...'); + + expect(moreButton, findsOneWidget); + expect(find.textContaining('1'), findsNothing); + + await tester.tap(moreButton); + await tester.pumpAndSettle(); + expect(find.textContaining('1'), findsOneWidget); + }); + + testWidgets("Item with leading, custom divider and label", (tester) async { + await tester.pumpWidget( + const TestWidget( + widgetKey: key, + showLeading: true, + ), + ); + + expect(find.textContaining('p'), findsWidgets); + + expect(find.byIcon(leadingIcon), findsWidgets); + expect(find.byIcon(dividerIcon), findsWidgets); + }); + + testWidgets("Test max itemsToShow", (tester) async { + await tester.pumpWidget( + const TestWidget( + widgetKey: key, + itemsToShow: 2, + ), + ); + final moreButton = find.text('...'); + + expect(moreButton, findsOneWidget); + expect(find.textContaining('p'), findsNWidgets(2)); + + await tester.tap(moreButton); + await tester.pumpAndSettle(); + expect(find.textContaining('p'), findsNWidgets(4)); + }); + + testWidgets("Press breadcrumb item", (tester) async { + var value = 0; + await tester.pumpWidget( + TestWidget( + widgetKey: key, + onPressed: (index) => value = index, + ), + ); + + await tester.tap(find.textContaining('2')); + await tester.pumpAndSettle(); + + expect(value, 2); + + await tester.tap(find.textContaining('0')); + await tester.pumpAndSettle(); + + expect(value, 0); + + final moreButton = find.text('...'); + + expect(moreButton, findsOneWidget); + await tester.tap(moreButton); + await tester.pumpAndSettle(); + + await tester.tap(find.textContaining('1')); + await tester.pumpAndSettle(); + + expect(value, 1); + }); +} + +const IconData leadingIcon = MoonIcons.other_frame_24_light; +const IconData dividerIcon = MoonIcons.arrows_chevron_right_double_16_light; + +class TestWidget extends StatelessWidget { + final bool showLeading; + final int? itemsToShow; + final void Function(int)? onPressed; + final Key? widgetKey; + + const TestWidget({ + super.key, + this.showLeading = false, + this.itemsToShow, + this.onPressed, + this.widgetKey, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: MoonBreadcrumb( + key: widgetKey, + visibleItemCount: itemsToShow ?? 3, + divider: const Icon(dividerIcon), + items: [ + ...List.generate(4, (i) => i).map( + (index) { + return MoonBreadcrumbItem( + label: Text('p$index'), + leading: showLeading ? const Icon(leadingIcon) : null, + onTap: () => onPressed?.call(index), + ); + }, + ), + ], + ), + ), + ), + ); + } +}