diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_icon.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_icon.dart index 0d8fde5a70..846a198f13 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_icon.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_icon.dart @@ -3,7 +3,6 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; - extension MobileSearchIconItemExtension on ResultIconPB { Widget? buildIcon(BuildContext context) { final theme = AppFlowyTheme.of(context); diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart index 76bf201976..b30ad053c1 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart @@ -1,9 +1,10 @@ import 'package:appflowy_ui/appflowy_ui.dart'; -import 'package:appflowy_ui_example/src/avatar/avatar_page.dart'; -import 'package:appflowy_ui_example/src/menu/menu_page.dart'; import 'package:flutter/material.dart'; +import 'src/avatar/avatar_page.dart'; import 'src/buttons/buttons_page.dart'; +import 'src/dropdown_menu/dropdown_menu_page.dart'; +import 'src/menu/menu_page.dart'; import 'src/modal/modal_page.dart'; import 'src/textfield/textfield_page.dart'; @@ -71,6 +72,7 @@ class _MyHomePageState extends State { Tab(text: 'Modal'), Tab(text: 'Avatar'), Tab(text: 'Menu'), + Tab(text: 'Dropdown Menu'), ]; @override @@ -106,6 +108,7 @@ class _MyHomePageState extends State { ModalPage(), AvatarPage(), MenuPage(), + DropdownMenuPage(), ], ), bottomNavigationBar: TabBar( diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/dropdown_menu/dropdown_menu_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/dropdown_menu/dropdown_menu_page.dart new file mode 100644 index 0000000000..4988bc51d6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/dropdown_menu/dropdown_menu_page.dart @@ -0,0 +1,122 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +class AFDropDownMenuItem with AFDropDownMenuMixin { + const AFDropDownMenuItem({ + required this.label, + }); + + @override + final String label; +} + +class DropdownMenuPage extends StatefulWidget { + const DropdownMenuPage({super.key}); + + @override + State createState() => _DropdownMenuPageState(); +} + +class _DropdownMenuPageState extends State { + List selectedItems = []; + bool isDisabled = false; + bool isMultiselect = false; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return Stack( + children: [ + Positioned( + left: theme.spacing.xxl, + top: theme.spacing.xxl, + child: Container( + padding: EdgeInsets.all( + theme.spacing.m, + ), + decoration: BoxDecoration( + color: theme.backgroundColorScheme.primary, + borderRadius: BorderRadius.circular(theme.borderRadius.m), + boxShadow: theme.shadow.medium, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildOption( + 'is disabled', + isDisabled, + (value) { + setState(() => isDisabled = value); + }, + ), + // _buildOption( + // 'multiselect', + // isMultiselect, + // (value) { + // setState(() => isMultiselect = value); + // }, + // ), + ], + ), + ), + ), + Center( + child: SizedBox( + width: 240, + child: AFDropDownMenu( + items: items, + selectedItems: selectedItems, + isDisabled: isDisabled, + // isMultiselect: isMultiselect, + onSelected: (value) { + if (value != null) { + setState(() { + if (isMultiselect) { + if (selectedItems.contains(value)) { + selectedItems.remove(value); + } else { + selectedItems.add(value); + } + } else { + selectedItems + ..clear() + ..add(value); + } + }); + } + }, + ), + ), + ), + ], + ); + } + + Widget _buildOption( + String label, + bool value, + void Function(bool) onChanged, + ) { + return Row( + children: [ + SizedBox( + width: 200, + child: Text(label), + ), + Switch( + value: value, + onChanged: onChanged, + ), + ], + ); + } + + static const items = [ + AFDropDownMenuItem(label: 'Item 1'), + AFDropDownMenuItem(label: 'Item 2'), + AFDropDownMenuItem(label: 'Item 3'), + AFDropDownMenuItem(label: 'Item 4'), + AFDropDownMenuItem(label: 'Item 5'), + ]; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart index 81c1cc1ecf..239ee22b39 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart @@ -25,6 +25,8 @@ class AFBaseButton extends StatefulWidget { this.backgroundColor, this.ringColor, this.disabled = false, + this.autofocus = false, + this.showFocusRing = true, }); final VoidCallback? onTap; @@ -36,6 +38,8 @@ class AFBaseButton extends StatefulWidget { final EdgeInsetsGeometry padding; final double borderRadius; final bool disabled; + final bool autofocus; + final bool showFocusRing; final Widget Function( BuildContext context, @@ -81,6 +85,7 @@ class _AFBaseButtonState extends State { onFocusChange: (isFocused) { setState(() => this.isFocused = isFocused); }, + autofocus: widget.autofocus, child: MouseRegion( cursor: widget.onTap == null ? SystemMouseCursors.basic @@ -94,7 +99,7 @@ class _AFBaseButtonState extends State { child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(widget.borderRadius), - border: isFocused + border: isFocused && widget.showFocusRing ? Border.all( color: ringColor, width: 2, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart index 1afba3d65c..68343135a9 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart @@ -1,4 +1,5 @@ export 'button/button.dart'; +export 'dropdown_menu/dropdown_menu.dart'; export 'separator/divider.dart'; export 'modal/modal.dart'; export 'textfield/textfield.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/dropdown_menu/dropdown_menu.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/dropdown_menu/dropdown_menu.dart new file mode 100644 index 0000000000..ffd2dc7964 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/dropdown_menu/dropdown_menu.dart @@ -0,0 +1,360 @@ +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:flutter/material.dart'; + +mixin AFDropDownMenuMixin { + String get label; +} + +class AFDropDownMenu extends StatefulWidget { + const AFDropDownMenu({ + super.key, + required this.items, + required this.selectedItems, + this.onSelected, + this.closeOnSelect, + this.controller, + this.isClearEnabled = true, + this.errorText, + this.isRequired = false, + this.isDisabled = false, + this.emptyLabel, + this.clearIcon, + this.dropdownIcon, + this.selectedIcon, + }); + + final List items; + final List selectedItems; + final void Function(T? value)? onSelected; + final bool? closeOnSelect; + final AFPopoverController? controller; + final String? errorText; + final bool isRequired; + final bool isDisabled; + final bool isMultiselect = false; + final bool isClearEnabled; + final String? emptyLabel; + final Widget? clearIcon; + final Widget? dropdownIcon; + final Widget? selectedIcon; + + @override + State> createState() => _AFDropDownMenuState(); +} + +class _AFDropDownMenuState + extends State> { + late final AFPopoverController controller; + bool isHovering = false; + bool isOpen = false; + + @override + void initState() { + super.initState(); + controller = widget.controller ?? AFPopoverController(); + controller.addListener(popoverListener); + } + + @override + void dispose() { + if (widget.controller == null) { + controller.dispose(); + } else { + controller.removeListener(popoverListener); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + return AFPopover( + controller: controller, + padding: EdgeInsets.zero, + anchor: AFAnchor( + childAlignment: Alignment.topCenter, + overlayAlignment: Alignment.bottomCenter, + offset: Offset(0, theme.spacing.xs), + ), + decoration: BoxDecoration( + color: theme.surfaceColorScheme.layer01, + borderRadius: BorderRadius.circular(theme.borderRadius.m), + boxShadow: theme.shadow.small, + ), + popover: (popoverContext) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth, + maxHeight: 300, + ), + child: _DropdownPopoverContents( + items: widget.items, + onSelected: (item) { + widget.onSelected?.call(item); + if ((widget.closeOnSelect == null && !widget.isMultiselect) || + widget.closeOnSelect == true) { + controller.hide(); + } + }, + selectedItems: widget.selectedItems, + selectedIcon: widget.selectedIcon, + isMultiselect: widget.isMultiselect, + ), + ); + }, + child: MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: GestureDetector( + onTap: () { + if (widget.isDisabled) { + return; + } + if (controller.isOpen) { + controller.hide(); + } else { + controller.show(); + } + }, + child: Container( + constraints: const BoxConstraints.tightFor(height: 32), + decoration: BoxDecoration( + border: Border.all( + color: widget.isDisabled + ? theme.borderColorScheme.primary + : isOpen + ? theme.borderColorScheme.themeThick + : isHovering + ? theme.borderColorScheme.primaryHover + : theme.borderColorScheme.primary, + ), + color: widget.isDisabled + ? theme.fillColorScheme.contentHover + : null, + borderRadius: BorderRadius.circular(theme.borderRadius.m), + ), + padding: EdgeInsets.symmetric( + horizontal: theme.spacing.m, + vertical: theme.spacing.xs, + ), + child: Row( + spacing: theme.spacing.xs, + children: [ + Expanded( + child: _DropdownButtonContents( + items: widget.selectedItems, + isMultiselect: widget.isMultiselect, + isDisabled: widget.isDisabled, + emptyLabel: widget.emptyLabel, + ), + ), + if (widget.isClearEnabled && + isOpen && + widget.clearIcon != null) + widget.clearIcon!, + widget.dropdownIcon ?? + SizedBox.square( + dimension: 20, + child: Icon( + Icons.arrow_drop_down, + size: 16, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + void popoverListener() { + setState(() { + isOpen = controller.isOpen; + }); + } +} + +class _DropdownButtonContents + extends StatelessWidget { + const _DropdownButtonContents({ + super.key, + required this.items, + this.isDisabled = false, + this.isMultiselect = false, + this.emptyLabel, + }); + + final List items; + final bool isMultiselect; + final bool isDisabled; + final String? emptyLabel; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + if (isMultiselect) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + child: Row( + spacing: theme.spacing.xs, + children: [ + ...items.map((item) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(theme.spacing.s), + color: theme.surfaceContainerColorScheme.layer02, + ), + padding: EdgeInsetsDirectional.fromSTEB( + theme.spacing.m, + 1.0, + theme.spacing.s, + 1.0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + item.label, + style: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), + ), + ), + Icon( + Icons.cancel, + size: 16, + color: theme.iconColorScheme.tertiary, + ), + ], + ), + ); + }), + TextField( + enabled: !isDisabled, + decoration: InputDecoration( + hintText: items.isEmpty ? emptyLabel ?? "(optional)" : null, + hintStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.tertiary, + ), + border: InputBorder.none, + constraints: const BoxConstraints(maxWidth: 120), + isCollapsed: true, + isDense: true, + ), + style: theme.textStyle.body.standard( + color: isDisabled + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary, + ), + ), + ], + ), + ); + } + + return Text( + items.isEmpty ? emptyLabel ?? "(optional)" : items.first.label, + style: theme.textStyle.body.standard( + color: isDisabled || items.isEmpty + ? theme.textColorScheme.tertiary + : theme.textColorScheme.primary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } +} + +class _DropdownPopoverContents + extends StatelessWidget { + const _DropdownPopoverContents({ + super.key, + required this.items, + this.selectedItems = const [], + this.onSelected, + this.isMultiselect = false, + this.selectedIcon, + }); + + final List items; + final List selectedItems; + final void Function(T? value)? onSelected; + final bool isMultiselect; + final Widget? selectedIcon; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + + return FocusScope( + autofocus: true, + child: ListView.builder( + itemCount: items.length, + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.all(theme.spacing.m), + shrinkWrap: true, + itemBuilder: itemBuilder, + ), + ); + } + + Widget itemBuilder(BuildContext context, int index) { + final theme = AppFlowyTheme.of(context); + final item = items[index]; + + return AFBaseButton( + padding: EdgeInsets.symmetric( + vertical: theme.spacing.s, + horizontal: theme.spacing.m, + ), + borderRadius: theme.borderRadius.m, + borderColor: (context, isHovering, disabled, isFocused) { + return Colors.transparent; + }, + showFocusRing: false, + builder: (context, _, __) { + return Row( + spacing: theme.spacing.m, + children: [ + Expanded( + child: Text( + item.label, + style: theme.textStyle.body + .standard(color: theme.textColorScheme.primary) + .copyWith(overflow: TextOverflow.ellipsis), + ), + ), + if (selectedItems.contains(item) && isMultiselect) + selectedIcon ?? + Icon( + Icons.check, + color: theme.fillColorScheme.themeThick, + size: 20.0, + ), + ], + ); + }, + backgroundColor: (context, isHovering, _) { + if (selectedItems.contains(item) && !isMultiselect) { + return theme.fillColorScheme.themeSelect; + } + if (isHovering) { + return theme.fillColorScheme.contentHover; + } + return Colors.transparent; + }, + onTap: () { + onSelected?.call(item); + }, + ); + } +}