feat: select option cell editor revamp (#5011)

* chore: gen new select option color on frontend

* chore: reorder select options

* chore: fix performance regression

* chore: add text field tap region

* chore: implement hover focus

* chore: implement keyboard focus

* chore: fix tests

* chore: reorder options in field editor

* chore: fix tests
This commit is contained in:
Richard Shiue
2024-03-31 10:54:17 +08:00
committed by GitHub
parent adc2ee755e
commit 419464c175
21 changed files with 1186 additions and 833 deletions

View File

@ -7,6 +7,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart';
@ -16,29 +17,29 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
constraints: BoxConstraints.loose(const Size.square(300)),
constraints: const BoxConstraints.tightFor(width: 300),
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cellContainerNotifier.isFocus = true;
});
return SelectOptionCellEditor(
cellController: bloc.cellController,
);
},
onClose: () => cellContainerNotifier.isFocus = false,
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
);
},
),
);
}

View File

@ -8,6 +8,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart';
@ -18,12 +19,11 @@ class DesktopRowDetailSelectOptionCellSkin
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
constraints: BoxConstraints.loose(const Size.square(300)),
constraints: const BoxConstraints.tightFor(width: 300),
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
@ -35,14 +35,18 @@ class DesktopRowDetailSelectOptionCellSkin
);
},
onClose: () => cellContainerNotifier.isFocus = false,
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: state.selectedOptions.isEmpty
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0)
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0),
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: state.selectedOptions.isEmpty
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0)
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0),
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
);
},
),
);
}

View File

@ -32,7 +32,6 @@ abstract class IEditableSelectOptionCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
);
}
@ -77,16 +76,11 @@ class _SelectOptionCellState extends GridCellState<EditableSelectOptionCell> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
state,
_popover,
);
},
child: widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
_popover,
),
);
}

View File

@ -8,6 +8,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart';
@ -17,25 +18,28 @@ class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return FlowyButton(
hoverColor: Colors.transparent,
radius: BorderRadius.zero,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
),
onTap: () {
showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
return BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return FlowyButton(
hoverColor: Colors.transparent,
radius: BorderRadius.zero,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: state.selectedOptions.isEmpty
? const SizedBox.shrink()
: _buildOptions(context, state.selectedOptions),
),
onTap: () {
showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
);
},
);
},
);

View File

@ -10,6 +10,7 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/select_option.dart';
@ -20,53 +21,56 @@ class MobileRowDetailSelectOptionCellSkin
BuildContext context,
CellContainerNotifier cellContainerNotifier,
SelectOptionCellBloc bloc,
SelectOptionCellState state,
PopoverController popoverController,
) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: state.selectedOptions.isEmpty ? 13 : 10,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
return BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
child: Row(
children: [
Expanded(
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
onTap: () => showMobileBottomSheet(
context,
builder: (context) {
return MobileSelectOptionEditor(
cellController: bloc.cellController,
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
const HSpace(6),
RotatedBox(
quarterTurns: 3,
child: Icon(
Icons.chevron_left,
color: Theme.of(context).hintColor,
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: state.selectedOptions.isEmpty ? 13 : 10,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
const HSpace(2),
],
),
),
child: Row(
children: [
Expanded(
child: state.selectedOptions.isEmpty
? _buildPlaceholder(context)
: _buildOptions(context, state.selectedOptions),
),
const HSpace(6),
RotatedBox(
quarterTurns: 3,
child: Icon(
Icons.chevron_left,
color: Theme.of(context).hintColor,
),
),
const HSpace(2),
],
),
),
);
},
);
}

View File

@ -33,7 +33,7 @@ extension SelectOptionColorExtension on SelectOptionColorPB {
}
}
String optionName() {
String colorName() {
switch (this) {
case SelectOptionColorPB.Purple:
return LocaleKeys.grid_selectOption_purpleColor.tr();
@ -123,44 +123,3 @@ class SelectOptionTag extends StatelessWidget {
}
}
class SelectOptionTagCell extends StatelessWidget {
const SelectOptionTagCell({
super.key,
required this.option,
required this.onSelected,
this.children = const [],
});
final SelectOptionPB option;
final VoidCallback onSelected;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onSelected,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 5.0,
vertical: 4.0,
),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
),
),
...children,
],
);
}
}

View File

@ -5,7 +5,7 @@ import 'package:appflowy/mobile/presentation/base/option_color_list.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
@ -55,8 +55,9 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
child: BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
),
child: BlocBuilder<SelectOptionCellEditorBloc,
SelectOptionCellEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
@ -110,7 +111,7 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
onDelete: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(option!));
.add(SelectOptionCellEditorEvent.deleteOption(option!));
_popOrBack();
},
onUpdate: (name, color) {
@ -120,7 +121,7 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
}
option.freeze();
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.updateOption(
SelectOptionCellEditorEvent.updateOption(
option.rebuild((p0) {
if (name != null) {
p0.name = name;
@ -142,16 +143,16 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
_SearchField(
controller: searchController,
hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(),
onSubmitted: (option) {
onSubmitted: (_) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(option));
.add(const SelectOptionCellEditorEvent.submitTextField());
searchController.clear();
},
onChanged: (value) {
typingOption = value;
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
SelectOptionCellEditorEvent.selectMultipleOptions(
[],
value,
),
@ -164,18 +165,18 @@ class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
onCreateOption: (optionName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(optionName));
.add(const SelectOptionCellEditorEvent.createOption());
searchController.clear();
},
onCheck: (option, value) {
if (value) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(option.id));
.add(SelectOptionCellEditorEvent.selectOption(option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(option.id));
.add(SelectOptionCellEditorEvent.unSelectOption(option.id));
}
},
onMoreOptions: (option) {
@ -253,18 +254,20 @@ class _OptionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
builder: (context, state) {
// existing options
final List<Widget> cells = [];
// create an option cell
final createOption = state.createOption;
if (createOption != null) {
if (state.createSelectOptionSuggestion != null) {
cells.add(
_CreateOptionCell(
optionName: createOption,
onTap: () => onCreateOption(createOption),
name: state.createSelectOptionSuggestion!.name,
color: state.createSelectOptionSuggestion!.color,
onTap: () => onCreateOption(
state.createSelectOptionSuggestion!.name,
),
),
);
}
@ -332,14 +335,17 @@ class _SelectOption extends StatelessWidget {
const HSpace(12),
// option tag
Expanded(
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 10,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
),
textAlign: TextAlign.center,
fontSize: 15.0,
),
textAlign: TextAlign.center,
fontSize: 15.0,
isExpanded: true,
),
),
const HSpace(24),
@ -359,11 +365,13 @@ class _SelectOption extends StatelessWidget {
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.optionName,
required this.name,
required this.color,
required this.onTap,
});
final String optionName;
final String name;
final SelectOptionColorPB color;
final VoidCallback onTap;
@override
@ -381,13 +389,16 @@ class _CreateOptionCell extends StatelessWidget {
),
const HSpace(8),
Expanded(
child: SelectOptionTag(
isExpanded: true,
name: optionName,
color: Theme.of(context).colorScheme.surfaceVariant,
textAlign: TextAlign.center,
padding: const EdgeInsets.symmetric(
vertical: 10,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: SelectOptionTag(
name: name,
color: color.toColor(context),
textAlign: TextAlign.center,
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
),
),
),
),

View File

@ -1,18 +1,20 @@
import 'dart:collection';
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/cell/bloc/select_option_editor_bloc.dart';
import '../../grid/presentation/layout/sizes.dart';
import '../../grid/presentation/widgets/common/type_option_separator.dart';
import '../field/type_option_editor/select/select_option_editor.dart';
@ -33,39 +35,81 @@ class SelectOptionCellEditor extends StatefulWidget {
class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
final TextEditingController textEditingController = TextEditingController();
final popoverMutex = PopoverMutex();
late final bloc = SelectOptionCellEditorBloc(
cellController: widget.cellController,
);
late final FocusNode focusNode;
@override
void initState() {
super.initState();
focusNode = FocusNode(
onKeyEvent: (node, event) {
if (event is KeyUpEvent) {
return KeyEventResult.ignored;
}
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp:
if (textEditingController.value.composing.isCollapsed) {
bloc.add(const SelectOptionCellEditorEvent.focusPreviousOption());
return KeyEventResult.handled;
}
case LogicalKeyboardKey.arrowDown:
if (textEditingController.value.composing.isCollapsed) {
bloc.add(const SelectOptionCellEditorEvent.focusNextOption());
return KeyEventResult.handled;
}
case LogicalKeyboardKey.escape:
if (!textEditingController.value.composing.isCollapsed) {
final end = textEditingController.value.composing.end;
final text = textEditingController.text;
textEditingController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: end),
);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
);
}
@override
void dispose() {
popoverMutex.dispose();
textEditingController.dispose();
bloc.close();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_TextField(
textEditingController: textEditingController,
popoverMutex: popoverMutex,
),
const TypeOptionSeparator(spacing: 0.0),
Flexible(
return BlocProvider.value(
value: bloc,
child: TextFieldTapRegion(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TextField(
textEditingController: textEditingController,
focusNode: focusNode,
popoverMutex: popoverMutex,
),
const TypeOptionSeparator(spacing: 0.0),
Flexible(
child: Focus(
descendantsAreFocusable: false,
child: _OptionList(
textEditingController: textEditingController,
popoverMutex: popoverMutex,
),
),
],
);
},
),
],
),
),
);
}
@ -82,60 +126,83 @@ class _OptionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
buildWhen: (previous, current) =>
!listEquals(previous.options, current.options) ||
previous.createSelectOptionSuggestion !=
current.createSelectOptionSuggestion,
builder: (context, state) {
final cells = [
_Title(onPressedAddButton: () => onPressedAddButton(context)),
...state.options.map(
(option) => _SelectOptionCell(
option: option,
isSelected: state.selectedOptions.contains(option),
popoverMutex: popoverMutex,
return ReorderableListView.builder(
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
value: context.read<SelectOptionCellEditorBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
];
final createOption = state.createOption;
if (createOption != null) {
cells.add(_CreateOptionCell(name: createOption));
}
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
physics: StyledScrollPhysics(),
itemBuilder: (_, int index) => cells[index],
buildDefaultDragHandles: false,
itemCount: state.options.length,
onReorderStart: (_) => popoverMutex.close(),
itemBuilder: (_, int index) {
final option = state.options[index];
return _SelectOptionCell(
key: ValueKey("select_cell_option_list_${option.id}"),
index: index,
option: option,
popoverMutex: popoverMutex,
);
},
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
final fromOptionId = state.options[oldIndex].id;
final toOptionId = state.options[newIndex].id;
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.reorderOption(
fromOptionId,
toOptionId,
),
);
},
header: const _Title(),
footer: state.createSelectOptionSuggestion == null
? null
: _CreateOptionCell(
suggestion: state.createSelectOptionSuggestion!,
),
padding: const EdgeInsets.symmetric(vertical: 8.0),
);
},
);
}
void onPressedAddButton(BuildContext context) {
final text = textEditingController.text;
if (text.isNotEmpty) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(text));
}
textEditingController.clear();
}
}
class _TextField extends StatelessWidget {
const _TextField({
required this.textEditingController,
required this.focusNode,
required this.popoverMutex,
});
final TextEditingController textEditingController;
final FocusNode focusNode;
final PopoverMutex popoverMutex;
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionCellEditorState>(
builder: (context, state) {
final optionMap = LinkedHashMap<String, SelectOptionPB>.fromIterable(
state.selectedOptions,
@ -143,40 +210,46 @@ class _TextField extends StatelessWidget {
value: (option) => option,
);
return Padding(
padding: const EdgeInsets.all(12.0),
child: SelectOptionTextField(
options: state.options,
selectedOptionMap: optionMap,
distanceToText: _editorPanelWidth * 0.7,
textController: textEditingController,
textSeparators: const [','],
onClick: () => popoverMutex.close(),
newText: (text) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.filterOption(text));
},
onSubmitted: (tagName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(tagName));
},
onPaste: (tagNames, remainder) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
tagNames,
remainder,
),
);
},
onRemove: (optionName) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.unSelectOption(
optionMap[optionName]!.id,
),
);
},
return Material(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: SelectOptionTextField(
options: state.options,
focusNode: focusNode,
selectedOptionMap: optionMap,
distanceToText: _editorPanelWidth * 0.7,
textController: textEditingController,
textSeparators: const [','],
onClick: () => popoverMutex.close(),
newText: (text) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionCellEditorEvent.filterOption(text));
},
onSubmitted: () {
context
.read<SelectOptionCellEditorBloc>()
.add(const SelectOptionCellEditorEvent.submitTextField());
textEditingController.clear();
focusNode.requestFocus();
},
onPaste: (tagNames, remainder) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.selectMultipleOptions(
tagNames,
remainder,
),
);
},
onRemove: (optionName) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.unSelectOption(
optionMap[optionName]!.id,
),
);
},
),
),
);
},
@ -185,11 +258,7 @@ class _TextField extends StatelessWidget {
}
class _Title extends StatelessWidget {
const _Title({
required this.onPressedAddButton,
});
final VoidCallback onPressedAddButton;
const _Title();
@override
Widget build(BuildContext context) {
@ -197,62 +266,9 @@ class _Title extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: Row(
children: [
Flexible(
child: FlowyText.medium(
LocaleKeys.grid_selectOption_panelTitle.tr(),
color: Theme.of(context).hintColor,
),
),
],
),
),
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.name,
});
final String name;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox(
height: 28,
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () => context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(name)),
text: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(10),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SelectOptionTag(
name: name,
fontSize: 11,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
color: Theme.of(context).colorScheme.surfaceVariant,
),
),
),
],
),
child: FlowyText.regular(
LocaleKeys.grid_selectOption_panelTitle.tr(),
color: Theme.of(context).hintColor,
),
),
);
@ -261,13 +277,14 @@ class _CreateOptionCell extends StatelessWidget {
class _SelectOptionCell extends StatefulWidget {
const _SelectOptionCell({
super.key,
required this.option,
required this.isSelected,
required this.index,
required this.popoverMutex,
});
final SelectOptionPB option;
final bool isSelected;
final int index;
final PopoverMutex popoverMutex;
@override
@ -285,34 +302,6 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: 28,
child: SelectOptionTagCell(
option: widget.option,
onSelected: _onTap,
children: [
if (widget.isSelected)
FlowyIconButton(
width: 20,
hoverColor: Colors.transparent,
onPressed: _onTap,
icon: FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).iconTheme.color,
),
),
FlowyIconButton(
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.details_s,
color: Theme.of(context).iconTheme.color,
),
),
],
),
);
return AppFlowyPopover(
controller: _popoverController,
offset: const Offset(8, 0),
@ -322,13 +311,59 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
mutex: widget.popoverMutex,
clickHandler: PopoverClickHandler.gestureDetector,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: FlowyHover(
resetHoverOnRebuild: false,
style: HoverStyle(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: MouseRegion(
onEnter: (_) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionCellEditorEvent.updateFocusedOption(
widget.option.id,
),
);
},
child: Container(
height: 28,
decoration: BoxDecoration(
color: context
.watch<SelectOptionCellEditorBloc>()
.state
.focusedOptionId ==
widget.option.id
? AFThemeExtension.of(context).lightGreyHover
: null,
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: SelectOptionTagCell(
option: widget.option,
index: widget.index,
onSelected: _onTap,
children: [
if (context
.watch<SelectOptionCellEditorBloc>()
.state
.selectedOptions
.contains(widget.option))
FlowyIconButton(
width: 20,
hoverColor: Colors.transparent,
onPressed: _onTap,
icon: FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).iconTheme.color,
),
),
FlowyIconButton(
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.three_dots_s,
size: const Size.square(16),
color: Theme.of(context).colorScheme.onBackground,
),
),
],
),
),
child: child,
),
),
popupBuilder: (BuildContext popoverContext) {
@ -337,13 +372,13 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
onDeleted: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(widget.option));
.add(SelectOptionCellEditorEvent.deleteOption(widget.option));
PopoverContainer.of(popoverContext).close();
},
onUpdated: (updatedOption) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.updateOption(updatedOption));
.add(SelectOptionCellEditorEvent.updateOption(updatedOption));
},
key: ValueKey(
widget.option.id,
@ -355,14 +390,149 @@ class _SelectOptionCellState extends State<_SelectOptionCell> {
void _onTap() {
widget.popoverMutex.close();
if (widget.isSelected) {
if (context
.read<SelectOptionCellEditorBloc>()
.state
.selectedOptions
.contains(widget.option)) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(widget.option.id));
.add(SelectOptionCellEditorEvent.unSelectOption(widget.option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(widget.option.id));
.add(SelectOptionCellEditorEvent.selectOption(widget.option.id));
}
}
}
class SelectOptionTagCell extends StatelessWidget {
const SelectOptionTagCell({
super.key,
required this.option,
required this.onSelected,
this.children = const [],
this.index,
});
final SelectOptionPB option;
final VoidCallback onSelected;
final List<Widget> children;
final int? index;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (index != null)
ReorderableDragStartListener(
index: index!,
child: MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: GestureDetector(
onTap: onSelected,
child: SizedBox(
width: 26,
child: Center(
child: FlowySvg(
FlowySvgs.drag_element_s,
size: const Size.square(14),
color: Theme.of(context).colorScheme.onBackground,
),
),
),
),
),
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onSelected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 5.0,
vertical: 4.0,
),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
),
),
),
...children,
],
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.suggestion,
});
final CreateSelectOptionSuggestion suggestion;
@override
Widget build(BuildContext context) {
return Container(
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
context.watch<SelectOptionCellEditorBloc>().state.focusedOptionId ==
createSelectOptionSuggestionId
? AFThemeExtension.of(context).lightGreyHover
: null,
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: GestureDetector(
onTap: () => context
.read<SelectOptionCellEditorBloc>()
.add(const SelectOptionCellEditorEvent.createOption()),
child: MouseRegion(
onEnter: (_) {
context.read<SelectOptionCellEditorBloc>().add(
const SelectOptionCellEditorEvent.updateFocusedOption(
createSelectOptionSuggestionId,
),
);
},
child: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(10),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SelectOptionTag(
name: suggestion.name,
color: suggestion.color.toColor(context),
fontSize: 11,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
),
),
),
],
),
),
),
);
}
}

View File

@ -4,9 +4,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities
import 'package:flowy_infra/size.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:flutter/services.dart';
import 'extension.dart';
@ -18,6 +15,7 @@ class SelectOptionTextField extends StatefulWidget {
required this.distanceToText,
required this.textSeparators,
required this.textController,
required this.focusNode,
required this.onSubmitted,
required this.newText,
required this.onPaste,
@ -30,8 +28,9 @@ class SelectOptionTextField extends StatefulWidget {
final double distanceToText;
final List<String> textSeparators;
final TextEditingController textController;
final FocusNode focusNode;
final Function(String) onSubmitted;
final Function() onSubmitted;
final Function(String) newText;
final Function(List<String>, String) onPaste;
final Function(String) onRemove;
@ -42,32 +41,11 @@ class SelectOptionTextField extends StatefulWidget {
}
class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
late final FocusNode focusNode;
@override
void initState() {
super.initState();
focusNode = FocusNode(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
if (!widget.textController.value.composing.isCollapsed) {
final TextRange(:start, :end) =
widget.textController.value.composing;
final text = widget.textController.text;
widget.textController.value = TextEditingValue(
text: "${text.substring(0, start)}${text.substring(end)}",
selection: TextSelection(baseOffset: start, extentOffset: start),
);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
widget.focusNode.requestFocus();
});
widget.textController.addListener(_onChanged);
}
@ -75,7 +53,6 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
@override
void dispose() {
widget.textController.removeListener(_onChanged);
focusNode.dispose();
super.dispose();
}
@ -83,15 +60,9 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
Widget build(BuildContext context) {
return TextField(
controller: widget.textController,
focusNode: focusNode,
focusNode: widget.focusNode,
onTap: widget.onClick,
onSubmitted: (text) {
if (text.isNotEmpty) {
widget.onSubmitted(text.trim());
focusNode.requestFocus();
widget.textController.clear();
}
},
onSubmitted: (_) => widget.onSubmitted(),
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
@ -100,11 +71,6 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
),
isDense: true,
prefixIcon: _renderTags(context),
hintText: LocaleKeys.grid_selectOption_searchOption.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
prefixIconConstraints: BoxConstraints(maxWidth: widget.distanceToText),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
@ -148,23 +114,26 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
)
.toList();
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(spacing: 4, children: children),
return Focus(
descendantsAreFocusable: false,
child: MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(spacing: 4, children: children),
),
),
),
),

View File

@ -1,9 +1,11 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/field/type_option/select_option_type_option_bloc.dart';
import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
@ -48,16 +50,15 @@ class SelectOptionTypeOptionWidget extends StatelessWidget {
] else
const _AddOptionButton(),
const VSpace(4),
...state.options.map((option) {
return _OptionCell(
option: option,
Flexible(
child: _OptionList(
popoverMutex: popoverMutex,
);
}),
),
),
];
return ListView(
shrinkWrap: true,
return Column(
mainAxisSize: MainAxisSize.min,
children: children,
);
},
@ -90,9 +91,15 @@ class _OptionTitle extends StatelessWidget {
}
class _OptionCell extends StatefulWidget {
const _OptionCell({required this.option, this.popoverMutex});
const _OptionCell({
super.key,
required this.option,
required this.index,
this.popoverMutex,
});
final SelectOptionPB option;
final int index;
final PopoverMutex? popoverMutex;
@override
@ -108,6 +115,7 @@ class _OptionCellState extends State<_OptionCell> {
height: 28,
child: SelectOptionTagCell(
option: widget.option,
index: widget.index,
onSelected: () => _popoverController.show(),
children: [
FlowyIconButton(
@ -115,8 +123,9 @@ class _OptionCellState extends State<_OptionCell> {
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.details_s,
FlowySvgs.three_dots_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(16),
),
),
],
@ -253,3 +262,61 @@ class _CreateOptionTextFieldState extends State<CreateOptionTextField> {
super.dispose();
}
}
class _OptionList extends StatelessWidget {
const _OptionList({
this.popoverMutex,
});
final PopoverMutex? popoverMutex;
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionTypeOptionBloc, SelectOptionTypeOptionState>(
builder: (context, state) {
return ReorderableListView.builder(
shrinkWrap: true,
onReorderStart: (_) => popoverMutex?.close(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
value: context.read<SelectOptionTypeOptionBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
itemBuilder: (context, index) => _OptionCell(
key: ValueKey("select_type_option_list_${state.options[index].id}"),
index: index,
option: state.options[index],
popoverMutex: popoverMutex,
),
itemCount: state.options.length,
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
final fromOptionId = state.options[oldIndex].id;
final toOptionId = state.options[newIndex].id;
context.read<SelectOptionTypeOptionBloc>().add(
SelectOptionTypeOptionEvent.reorderOption(
fromOptionId,
toOptionId,
),
);
},
);
},
);
}
}

View File

@ -230,7 +230,7 @@ class _SelectOptionColorCell extends StatelessWidget {
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
color.optionName(),
color.colorName(),
color: AFThemeExtension.of(context).textColor,
),
leftIcon: colorIcon,