diff --git a/example/lib/home.dart b/example/lib/home.dart index 85fb9327..db27ea5d 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -10,6 +10,7 @@ import 'package:zeta_example/pages/components/button_example.dart'; import 'package:zeta_example/pages/components/checkbox_example.dart'; import 'package:zeta_example/pages/components/chip_example.dart'; 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/theme/color_example.dart'; @@ -42,6 +43,7 @@ final List components = [ Component(ListItemExample.name, (context) => const ListItemExample()), Component(NavigationBarExample.name, (context) => const NavigationBarExample()), Component(PasswordInputExample.name, (context) => const PasswordInputExample()), + Component(DropdownExample.name, (context) => const DropdownExample()), Component(ProgressExample.name, (context) => const ProgressExample()), Component(DialPadExample.name, (context) => const DialPadExample()), ]; diff --git a/example/lib/pages/components/dropdown_example.dart b/example/lib/pages/components/dropdown_example.dart new file mode 100644 index 00000000..0b37dcc2 --- /dev/null +++ b/example/lib/pages/components/dropdown_example.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class DropdownExample extends StatefulWidget { + static const String name = "Dropdown"; + const DropdownExample({super.key}); + + @override + State createState() => _DropdownExampleState(); +} + +class _DropdownExampleState extends State { + ZetaDropdownItem selectedItem = ZetaDropdownItem( + value: "Item 1", + leadingIcon: Icon(ZetaIcons.star_round), + ); + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: "Dropdown", + child: Center( + child: SingleChildScrollView( + child: SizedBox( + width: 320, + child: Column(children: [ + ZetaDropdown( + leadingType: LeadingStyle.checkbox, + onChange: (value) { + setState(() { + selectedItem = value; + }); + }, + selectedItem: selectedItem, + items: [ + ZetaDropdownItem( + value: "Item 1", + leadingIcon: Icon(ZetaIcons.star_round), + ), + ZetaDropdownItem( + value: "Item 2", + leadingIcon: Icon(ZetaIcons.star_half_round), + ), + ZetaDropdownItem( + value: "Item 3", + ) + ], + ), + Text('Selected item : ${selectedItem.value}') + ])), + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index cee55d3b..55bc1f9e 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -13,6 +13,7 @@ import 'pages/components/button_widgetbook.dart'; import 'pages/components/checkbox_widgetbook.dart'; import 'pages/components/chip_widgetbook.dart'; import 'pages/components/dial_pad_widgetbook.dart'; +import 'pages/components/dropdown_widgetbook.dart'; import 'pages/components/in_page_banner_widgetbook.dart'; import 'pages/components/list_item_widgetbook.dart'; import 'pages/components/navigation_bar_widgetbook.dart'; @@ -64,6 +65,7 @@ class HotReload extends StatelessWidget { ), WidgetbookUseCase(name: 'BreadCrumbs', builder: (context) => breadCrumbsUseCase(context)), WidgetbookUseCase(name: 'Banners', builder: (context) => bannerUseCase(context)), + WidgetbookUseCase(name: "Dropdown", builder: (context) => dropdownUseCase(context)), WidgetbookUseCase(name: 'In Page Banners', builder: (context) => inPageBannerUseCase(context)), WidgetbookUseCase(name: 'Accordion', builder: (context) => accordionUseCase(context)), WidgetbookComponent( diff --git a/example/widgetbook/pages/components/dropdown_widgetbook.dart b/example/widgetbook/pages/components/dropdown_widgetbook.dart new file mode 100644 index 00000000..8cdc9191 --- /dev/null +++ b/example/widgetbook/pages/components/dropdown_widgetbook.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget dropdownUseCase(BuildContext context) => WidgetbookTestWidget( + widget: Center( + child: DropdownExample(context), + ), + ); + +class DropdownExample extends StatefulWidget { + const DropdownExample(this.c); + final BuildContext c; + + @override + State createState() => _DropdownExampleState(); +} + +class _DropdownExampleState extends State { + List _children = [ + ZetaDropdownItem( + value: "Item 1", + leadingIcon: Icon(ZetaIcons.star_round), + ), + ZetaDropdownItem( + value: "Item 2", + leadingIcon: Icon(ZetaIcons.star_half_round), + ), + ZetaDropdownItem( + value: "Item 3", + ) + ]; + + late ZetaDropdownItem selectedItem = ZetaDropdownItem( + value: "Item 1", + leadingIcon: Icon(ZetaIcons.star_round), + ); + + @override + Widget build(BuildContext _) { + return SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: Column(children: [ + ZetaDropdown( + leadingType: widget.c.knobs.list( + label: "Checkbox type", + options: [ + LeadingStyle.none, + LeadingStyle.checkbox, + LeadingStyle.radio, + ], + ), + onChange: (value) { + setState(() { + selectedItem = value; + }); + }, + selectedItem: selectedItem, + items: _children, + rounded: widget.c.knobs.boolean(label: "Rounded"), + isMinimized: widget.c.knobs.boolean(label: "Minimized"), + ), + Text('Selected item : ${selectedItem.value}') + ])), + ); + } +} diff --git a/lib/src/components/breadcrumbs/breadcrumbs.dart b/lib/src/components/breadcrumbs/breadcrumbs.dart index b1a79d43..51536248 100644 --- a/lib/src/components/breadcrumbs/breadcrumbs.dart +++ b/lib/src/components/breadcrumbs/breadcrumbs.dart @@ -184,32 +184,29 @@ class _ZetaBreadCrumbState extends State { @override Widget build(BuildContext context) { final colors = Zeta.of(context).colors; - return Material( - color: Colors.transparent, - child: InkWell( - statesController: controller, - onTap: widget.onPressed, - enableFeedback: false, - splashColor: Colors.transparent, - overlayColor: MaterialStateProperty.resolveWith((states) { - return Colors.transparent; - }), - child: Row( - children: [ - if (widget.isSelected) - Icon( - widget.activeIcon ?? ZetaIcons.star_round, - color: getColor(controller.value, colors), - ), - const SizedBox( - width: ZetaSpacing.xs, - ), - Text( - widget.label, - style: TextStyle(color: getColor(controller.value, colors)), + return InkWell( + statesController: controller, + onTap: widget.onPressed, + enableFeedback: false, + splashColor: Colors.transparent, + overlayColor: MaterialStateProperty.resolveWith((states) { + return Colors.transparent; + }), + child: Row( + children: [ + if (widget.isSelected) + Icon( + widget.activeIcon ?? ZetaIcons.star_round, + color: getColor(controller.value, colors), ), - ], - ), + const SizedBox( + width: ZetaSpacing.xs, + ), + Text( + widget.label, + style: TextStyle(color: getColor(controller.value, colors)), + ), + ], ), ); } diff --git a/lib/src/components/dropdown/dropdown.dart b/lib/src/components/dropdown/dropdown.dart new file mode 100644 index 00000000..0e761d81 --- /dev/null +++ b/lib/src/components/dropdown/dropdown.dart @@ -0,0 +1,444 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +/// Class for [ZetaDropdown] +class ZetaDropdown extends StatefulWidget { + ///Constructor of [ZetaDropdown] + const ZetaDropdown({ + super.key, + required this.items, + required this.onChange, + required this.selectedItem, + this.rounded = true, + this.leadingType = LeadingStyle.none, + this.isMinimized = false, + }); + + /// Input items as list of [ZetaDropdownItem] + final List items; + + /// Currently selected item + final ZetaDropdownItem selectedItem; + + /// Handles changes of dropdown menu + final ValueSetter onChange; + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// The style for the leading widget. Can be a checkbox or radio button + final LeadingStyle leadingType; + + /// If menu is minimised. + final bool isMinimized; + + @override + State createState() => _ZetaDropDownState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('leadingType', leadingType)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add( + ObjectFlagProperty>.has( + 'onChange', + onChange, + ), + ) + ..add(DiagnosticsProperty('isMinimized', isMinimized)); + } +} + +class _ZetaDropDownState extends State { + final OverlayPortalController _tooltipController = OverlayPortalController(); + final _link = LayerLink(); + final _menuKey = GlobalKey(); // declare a global key + + /// Returns if click event position is within the header. + bool _isInHeader( + Offset headerPosition, + Size headerSize, + Offset clickPosition, + ) { + return clickPosition.dx >= headerPosition.dx && + clickPosition.dx <= headerPosition.dx + headerSize.width && + clickPosition.dy >= headerPosition.dy && + clickPosition.dy <= headerPosition.dy + headerSize.height; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _size, + child: CompositedTransformTarget( + link: _link, + child: OverlayPortal( + controller: _tooltipController, + overlayChildBuilder: (BuildContext context) { + return CompositedTransformFollower( + link: _link, + targetAnchor: Alignment.bottomLeft, // Align overlay dropdown in its correct position + child: Align( + alignment: AlignmentDirectional.topStart, + child: TapRegion( + onTapOutside: (event) { + final headerBox = _menuKey.currentContext!.findRenderObject()! as RenderBox; + + final headerPosition = headerBox.localToGlobal(Offset.zero); + + if (!_isInHeader( + headerPosition, + headerBox.size, + event.position, + )) _tooltipController.hide(); + }, + child: ZetaDropDownMenu( + items: widget.items, + selected: widget.selectedItem.value, + width: _size, + boxType: widget.leadingType, + onPress: (item) { + if (item != null) { + widget.onChange(item); + } + _tooltipController.hide(); + }, + ), + ), + ), + ); + }, + child: widget.selectedItem.copyWith( + round: widget.rounded, + focus: _tooltipController.isShowing, + press: onTap, + inputKey: _menuKey, + ), + ), + ), + ); + } + + double get _size => widget.isMinimized ? 120 : 320; + + void onTap() { + _tooltipController.toggle(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty>>( + 'menuKey', + _menuKey, + ), + ); + } +} + +/// Checkbox enum for different checkbox types +enum LeadingStyle { + /// No Leading + none, + + /// Circular checkbox + checkbox, + + /// Square checkbox + radio +} + +/// Class for [ZetaDropdownItem] +class ZetaDropdownItem extends StatefulWidget { + ///Public constructor for [ZetaDropdownItem] + const ZetaDropdownItem({ + super.key, + required this.value, + this.leadingIcon, + }) : rounded = true, + selected = false, + leadingType = LeadingStyle.none, + itemKey = null, + onPress = null; + + const ZetaDropdownItem._({ + super.key, + required this.rounded, + required this.selected, + required this.value, + this.leadingIcon, + this.onPress, + this.leadingType, + this.itemKey, + }); + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// If [ZetaDropdownItem] is selected + final bool selected; + + /// Value of [ZetaDropdownItem] + final String value; + + /// Leading icon for [ZetaDropdownItem] + final Icon? leadingIcon; + + /// Handles clicking for [ZetaDropdownItem] + final VoidCallback? onPress; + + /// If checkbox is to be shown, the type of it. + final LeadingStyle? leadingType; + + /// Key for item + final GlobalKey? itemKey; + + /// Returns copy of [ZetaDropdownItem] with those private variables included + ZetaDropdownItem copyWith({ + bool? round, + bool? focus, + LeadingStyle? boxType, + VoidCallback? press, + GlobalKey? inputKey, + }) { + return ZetaDropdownItem._( + rounded: round ?? rounded, + selected: focus ?? selected, + onPress: press ?? onPress, + leadingType: boxType ?? leadingType, + itemKey: inputKey ?? itemKey, + value: value, + leadingIcon: leadingIcon, + key: key, + ); + } + + @override + State createState() => _ZetaDropdownMenuItemState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('selected', selected)) + ..add(StringProperty('value', value)) + ..add(ObjectFlagProperty.has('onPress', onPress)) + ..add(EnumProperty('leadingType', leadingType)) + ..add( + DiagnosticsProperty>?>( + 'itemKey', + itemKey, + ), + ); + } +} + +class _ZetaDropdownMenuItemState extends State { + final controller = MaterialStatesController(); + + @override + void initState() { + super.initState(); + controller.addListener(() { + if (context.mounted && mounted && !controller.value.contains(MaterialState.disabled)) { + setState(() {}); + } + }); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + return DefaultTextStyle( + style: ZetaTextStyles.bodyMedium, + child: OutlinedButton( + key: widget.itemKey, + onPressed: widget.onPress, + style: _getStyle(colors), + child: Row( + children: [ + const SizedBox(width: ZetaSpacing.x3), + _getLeadingWidget(), + const SizedBox(width: ZetaSpacing.x3), + Text( + widget.value, + ), + ], + ).paddingVertical(ZetaSpacing.x2_5), + ), + ); + } + + Widget _getLeadingWidget() { + switch (widget.leadingType!) { + case LeadingStyle.checkbox: + return Checkbox( + value: widget.selected, + onChanged: (val) { + widget.onPress!.call(); + }, + ); + case LeadingStyle.radio: + return Radio( + value: widget.selected, + groupValue: true, + onChanged: (val) { + widget.onPress!.call(); + }, + ); + case LeadingStyle.none: + return widget.leadingIcon ?? + const SizedBox( + width: 24, + ); + } + } + + ButtonStyle _getStyle(ZetaColors colors) { + return ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.hovered)) { + return colors.surfaceHovered; + } + + if (states.contains(MaterialState.pressed)) { + return colors.surfaceSelected; + } + + if (states.contains(MaterialState.disabled) || widget.onPress == null) { + return colors.surfaceDisabled; + } + return colors.surfacePrimary; + }), + foregroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return colors.textDisabled; + } + return colors.textDefault; + }), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, + ), + ), + side: MaterialStatePropertyAll( + widget.selected ? BorderSide(color: colors.primary.shade60) : BorderSide.none, + ), + padding: const MaterialStatePropertyAll(EdgeInsets.zero), + elevation: const MaterialStatePropertyAll(0), + overlayColor: const MaterialStatePropertyAll(Colors.transparent), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty( + 'controller', + controller, + ), + ); + } +} + +///Class for [ZetaDropDownMenu] +class ZetaDropDownMenu extends StatefulWidget { + ///Constructor for [ZetaDropDownMenu] + const ZetaDropDownMenu({ + super.key, + required this.items, + required this.onPress, + required this.selected, + this.rounded = false, + this.width, + this.boxType, + }); + + /// Input items for the menu + final List items; + + ///Handles clicking of item in menu + final ValueSetter onPress; + + /// If item in menu is the currently selected item + final String selected; + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// Width for menu + final double? width; + + /// If items have checkboxes, the type of that checkbox. + final LeadingStyle? boxType; + + @override + State createState() => _ZetaDropDownMenuState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + ObjectFlagProperty>.has( + 'onPress', + onPress, + ), + ) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DoubleProperty('width', width)) + ..add(EnumProperty('boxType', boxType)) + ..add(StringProperty('selected', selected)); + } +} + +class _ZetaDropDownMenuState extends State { + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + return Container( + decoration: BoxDecoration( + color: colors.surfacePrimary, + borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, + boxShadow: const [ + BoxShadow(blurRadius: 2, color: Color.fromRGBO(40, 51, 61, 0.04)), + BoxShadow( + blurRadius: 8, + color: Color.fromRGBO(96, 104, 112, 0.16), + blurStyle: BlurStyle.outer, + offset: Offset(0, 4), + ), + ], + ), + width: widget.width, + child: Builder( + builder: (BuildContext bcontext) { + return Column( + mainAxisSize: MainAxisSize.min, + children: widget.items.map((item) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + item.copyWith( + round: widget.rounded, + focus: widget.selected == item.value, + boxType: widget.boxType, + press: () { + widget.onPress(item); + }, + ), + const SizedBox(height: ZetaSpacing.x1), + ], + ); + }).toList(), + ); + }, + ), + ); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 964e8d14..8d7cde31 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -23,6 +23,7 @@ export 'src/components/buttons/icon_button.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/chips/chip.dart'; export 'src/components/dial_pad/dial_pad.dart'; +export 'src/components/dropdown/dropdown.dart'; export 'src/components/list_item/list_item.dart'; export 'src/components/navigation bar/navigation_bar.dart'; export 'src/components/password/password_input.dart';