feat: support select / multi select option (#4039)

* feat: implement select option cell

* feat: support adding new select option

* feat: support selecting / deselecting option

* feat: clear search field after adding new property

* feat: support updating option name
This commit is contained in:
Lucas.Xu 2023-11-29 09:38:53 +08:00 committed by GitHub
parent 8036d070ad
commit 7da759c662
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 778 additions and 26 deletions

View File

@ -11,12 +11,14 @@ Future<void> showMobileBottomSheet({
required BuildContext context,
required WidgetBuilder builder,
bool isDragEnabled = true,
ShapeBorder? shape,
}) async {
showModalBottomSheet(
context: context,
isScrollControlled: true,
enableDrag: isDragEnabled,
useSafeArea: true,
shape: shape,
builder: builder,
);
}

View File

@ -0,0 +1,49 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// include single select and multiple select
class EditSelectField extends StatefulWidget {
const EditSelectField({super.key});
@override
State<EditSelectField> createState() => _EditSelectFieldState();
}
class _EditSelectFieldState extends State<EditSelectField> {
@override
Widget build(BuildContext context) {
return Column(
children: [
_SearchField(
hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(),
),
],
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({
required this.hintText,
});
final String hintText;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: SizedBox(
height: 44, // the height is fixed.
child: FlowyTextField(
hintText: hintText,
),
),
);
}
}

View File

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
class DragHandler extends StatelessWidget {
const DragHandler({
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
height: 4,
width: 40,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
);
}
}

View File

@ -0,0 +1,638 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/select_option_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:protobuf/protobuf.dart';
// include single select and multiple select
class MobileSelectOptionEditor extends StatefulWidget {
const MobileSelectOptionEditor({
super.key,
required this.cellController,
});
final SelectOptionCellController cellController;
@override
State<MobileSelectOptionEditor> createState() =>
_MobileSelectOptionEditorState();
}
class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
final searchController = TextEditingController();
final renameController = TextEditingController();
String typingOption = '';
FieldType get fieldType => widget.cellController.fieldType;
bool showMoreOptions = false;
SelectOptionPB? option;
@override
void dispose() {
searchController.dispose();
renameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints.tightFor(height: 420),
child: BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandler(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _buildHeader(
context,
showSaveButton: state.createOption
.fold(() => false, (a) => a.isNotEmpty) ||
showMoreOptions,
),
),
const Divider(),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: showMoreOptions ? 0.0 : 16.0,
),
child: _buildBody(context),
),
),
],
);
},
),
),
);
}
Widget _buildHeader(BuildContext context, {required bool showSaveButton}) {
const iconWidth = 36.0;
const height = 44.0;
return Stack(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Align(
alignment: Alignment.centerLeft,
child: FlowyIconButton(
icon: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(iconWidth),
),
width: iconWidth,
iconPadding: EdgeInsets.zero,
onPressed: () => _popOrBack(),
),
),
SizedBox(
height: 44.0,
child: Align(
alignment: Alignment.center,
child: FlowyText.medium(
_headerTitle(),
fontSize: 18,
),
),
),
Align(
alignment: Alignment.centerRight,
child: !showSaveButton
? const HSpace(iconWidth)
: Container(
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
decoration: const BoxDecoration(
color: Color(0xFF00bcf0),
borderRadius: Corners.s10Border,
),
child: FlowyButton(
text: FlowyText(
LocaleKeys.button_save.tr(),
color: Colors.white,
),
useIntrinsicWidth: true,
onTap: () {
if (showMoreOptions) {
final option = this.option;
if (option == null) {
return;
}
option.freeze();
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.updateOption(
option.rebuild((p0) {
if (p0.name != renameController.text) {
p0.name = renameController.text;
}
}),
),
);
_popOrBack();
} else if (typingOption.isNotEmpty) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.trySelectOption(
typingOption,
),
);
searchController.clear();
}
},
),
),
),
].map((e) => SizedBox(height: height, child: e)).toList(),
);
}
Widget _buildBody(BuildContext context) {
if (showMoreOptions && option != null) {
return _MoreOptions(
option: option!,
controller: renameController..text = option!.name,
onDelete: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(option!));
context.pop();
},
onUpdate: (name, color) {
final option = this.option;
if (option == null) {
return;
}
option.freeze();
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.updateOption(
option.rebuild((p0) {
if (name != null) {
p0.name = name;
}
if (color != null) {
p0.color = color;
}
}),
),
);
_popOrBack();
},
);
}
return SingleChildScrollView(
child: Column(
children: [
_SearchField(
controller: searchController,
hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(),
onSubmitted: (option) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(option));
searchController.clear();
},
onChanged: (value) {
typingOption = value;
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
[],
value,
),
);
},
),
_OptionList(
onCreateOption: (optionName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(optionName));
searchController.clear();
},
onCheck: (option, value) {
if (value) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(option.id));
}
},
onMoreOptions: (option) {
setState(() {
this.option = option;
showMoreOptions = true;
});
},
),
],
),
);
}
String _headerTitle() {
switch (fieldType) {
case FieldType.SingleSelect:
return LocaleKeys.grid_field_singleSelectFieldName.tr();
case FieldType.MultiSelect:
return LocaleKeys.grid_field_multiSelectFieldName.tr();
default:
throw UnimplementedError();
}
}
void _popOrBack() {
if (showMoreOptions) {
setState(() {
showMoreOptions = false;
option = null;
});
} else {
context.pop();
}
}
}
class _SearchField extends StatelessWidget {
const _SearchField({
this.hintText,
required this.onChanged,
required this.onSubmitted,
required this.controller,
});
final String? hintText;
final void Function(String value) onChanged;
final void Function(String value) onSubmitted;
final TextEditingController controller;
@override
Widget build(BuildContext context) {
final textStyle = Theme.of(context).textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: SizedBox(
height: 44, // the height is fixed.
child: FlowyTextField(
autoFocus: false,
hintText: hintText,
textStyle: textStyle,
hintStyle: textStyle?.copyWith(color: Theme.of(context).hintColor),
onChanged: onChanged,
onSubmitted: onSubmitted,
controller: controller,
),
),
);
}
}
class _OptionList extends StatelessWidget {
const _OptionList({
required this.onCreateOption,
required this.onCheck,
required this.onMoreOptions,
});
final void Function(String optionName) onCreateOption;
final void Function(SelectOptionPB option, bool value) onCheck;
final void Function(SelectOptionPB option) onMoreOptions;
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
// existing options
final List<Widget> cells = [];
// create an option cell
state.createOption.fold(
() => null,
(createOption) {
cells.add(
_CreateOptionCell(
optionName: createOption,
onTap: () => onCreateOption(createOption),
),
);
},
);
cells.addAll(
state.options.map(
(option) => _SelectOption(
option: option,
checked: state.selectedOptions.contains(option),
onCheck: (value) => onCheck(option, value),
onMoreOptions: () => onMoreOptions(option),
),
),
);
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (_, int index) => cells[index],
padding: const EdgeInsets.only(bottom: 12.0),
);
},
);
}
}
class _SelectOption extends StatelessWidget {
const _SelectOption({
required this.option,
required this.checked,
required this.onCheck,
required this.onMoreOptions,
});
final SelectOptionPB option;
final bool checked;
final void Function(bool value) onCheck;
final VoidCallback onMoreOptions;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: GestureDetector(
// no need to add click effect, so using gesture detector
behavior: HitTestBehavior.translucent,
onTap: () => onCheck(!checked),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// check icon
FlowySvg(
checked
? FlowySvgs.m_checkbox_checked_s
: FlowySvgs.m_checkbox_uncheck_s,
size: const Size.square(24.0),
blendMode: null,
),
// padding
const HSpace(12),
// option tag
_SelectOptionTag(
optionName: option.name,
color: option.color.toColor(context),
),
const Spacer(),
// more options
FlowyIconButton(
icon: const FlowySvg(FlowySvgs.three_dots_s),
onPressed: onMoreOptions,
),
],
),
),
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.optionName,
required this.onTap,
});
final String optionName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(8),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: _SelectOptionTag(
optionName: optionName,
color: Theme.of(context).colorScheme.surfaceVariant,
),
),
),
],
),
),
);
}
}
class _SelectOptionTag extends StatelessWidget {
const _SelectOptionTag({
required this.optionName,
required this.color,
});
final String optionName;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 12.0,
),
decoration: BoxDecoration(
color: color,
borderRadius: Corners.s12Border,
),
child: FlowyText.regular(
optionName,
fontSize: 16,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor,
),
);
}
}
class _MoreOptions extends StatelessWidget {
const _MoreOptions({
required this.option,
required this.onDelete,
required this.onUpdate,
required this.controller,
});
final SelectOptionPB option;
final VoidCallback onDelete;
final void Function(String? name, SelectOptionColorPB? color) onUpdate;
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(8.0),
_buildRenameTextField(context),
const VSpace(16.0),
_buildDeleteButton(context),
const VSpace(16.0),
Padding(
padding: const EdgeInsets.only(left: 12.0),
child: FlowyText(
LocaleKeys.grid_field_optionTitle.tr(),
color: Theme.of(context).hintColor,
),
),
const VSpace(4.0),
_buildColorOptions(context),
],
),
);
}
Widget _buildRenameTextField(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(height: 52.0),
child: _DefaultDecorateBox(
showTopBorder: true,
showBottomBorder: true,
child: TextField(
controller: controller,
decoration: const InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.only(left: 16.0),
),
onChanged: (value) {},
onSubmitted: (value) => onUpdate(value, null),
),
),
);
}
Widget _buildDeleteButton(BuildContext context) {
return _DefaultDecorateBox(
showTopBorder: true,
showBottomBorder: true,
child: FlowyButton(
text: FlowyText(
LocaleKeys.button_delete.tr(),
fontSize: 16.0,
),
margin: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 16.0,
),
leftIcon: const FlowySvg(FlowySvgs.delete_s),
leftIconSize: const Size.square(24.0),
iconPadding: 8.0,
onTap: onDelete,
),
);
}
Widget _buildColorOptions(BuildContext context) {
return _DefaultDecorateBox(
showTopBorder: true,
showBottomBorder: true,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: GridView.count(
crossAxisCount: 6,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: SelectOptionColorPB.values.map(
(colorPB) {
final color = colorPB.toColor(context);
return GestureDetector(
onTap: () => onUpdate(null, colorPB),
child: Container(
margin: const EdgeInsets.all(
10.0,
),
decoration: BoxDecoration(
color: color,
borderRadius: Corners.s12Border,
border: Border.all(
color: option.color.value == colorPB.value
? const Color(0xff00C6F1)
: Theme.of(context).dividerColor,
),
),
),
);
},
).toList(),
),
),
);
}
}
class _DefaultDecorateBox extends StatelessWidget {
const _DefaultDecorateBox({
this.showTopBorder = true,
this.showBottomBorder = true,
required this.child,
});
final bool showTopBorder;
final bool showBottomBorder;
final Widget child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
),
bottom: BorderSide(
color: Theme.of(context).dividerColor,
),
),
),
child: child,
);
}
}

View File

@ -1,6 +1,10 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/mobile_select_option_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -171,31 +175,56 @@ class SelectOptionWrap extends StatefulWidget {
class _SelectOptionWrapState extends State<SelectOptionWrap> {
@override
Widget build(BuildContext context) {
final Widget child = _buildOptions(context);
final constraints = BoxConstraints.loose(
Size(SelectOptionCellEditor.editorPanelWidth, 300),
);
return AppFlowyPopover(
controller: widget.popoverController,
constraints: constraints,
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCellEditing(true);
});
return SelectOptionCellEditor(
cellController: widget.cellControllerBuilder.build()
as SelectOptionCellController,
);
},
onClose: () => widget.onCellEditing(false),
child: Padding(
padding: widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets,
child: child,
),
final cellController =
widget.cellControllerBuilder.build() as SelectOptionCellController;
Widget child = Padding(
padding: widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets,
child: _buildOptions(context),
);
if (PlatformExtension.isDesktopOrWeb) {
child = AppFlowyPopover(
controller: widget.popoverController,
constraints: constraints,
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCellEditing(true);
});
return SelectOptionCellEditor(
cellController: cellController,
);
},
onClose: () => widget.onCellEditing(false),
child: child,
);
} else {
child = FlowyButton(
text: child,
onTap: () {
showMobileBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Corners.s12Radius,
),
),
builder: (context) {
return MobileSelectOptionEditor(
cellController: cellController,
);
},
);
},
);
}
return child;
}
Widget _buildOptions(BuildContext context) {

View File

@ -70,12 +70,16 @@ class MobileAppearance extends BaseAppearance {
onSurface: const Color(0xffC5C6C7), // text/body color
);
final hintColor = brightness == Brightness.light
? const Color(0xff89909B)
: const Color(0xff96989C);
return ThemeData(
// color
primaryColor: colorTheme.primary, //primary 100
primaryColorLight: const Color(0xFF57B5F8), //primary 80
dividerColor: colorTheme.outline, //caption
hintColor: colorTheme.outline,
hintColor: hintColor,
disabledColor: colorTheme.outline,
scaffoldBackgroundColor: colorTheme.background,
appBarTheme: AppBarTheme(

View File

@ -27,6 +27,7 @@ class FlowyTextField extends StatefulWidget {
final BoxConstraints? prefixIconConstraints;
final BoxConstraints? suffixIconConstraints;
final BoxConstraints? hintTextConstraints;
final TextStyle? hintStyle;
const FlowyTextField({
super.key,
@ -52,6 +53,7 @@ class FlowyTextField extends StatefulWidget {
this.prefixIconConstraints,
this.suffixIconConstraints,
this.hintTextConstraints,
this.hintStyle,
});
@override
@ -153,10 +155,11 @@ class FlowyTextFieldState extends State<FlowyTextField> {
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).colorScheme.error),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
hintStyle: widget.hintStyle ??
Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
suffixText: widget.showCounter ? _suffixText() : "",
counterText: "",
focusedBorder: OutlineInputBorder(

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="10" fill="#42ABF6"/>
<path d="M6.25 10.3125L9.27885 13.125L14.6875 7.5" stroke="white" stroke-width="1.6875" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="19" height="19" rx="9.5" stroke="#1F2329" stroke-opacity="0.15"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B