chore: revamp checklist ui (#3380)

* chore: revamp checklist editor  ui

* chore: checklist progress bar

* test: integration tests

* fix: flutter analyzer errors

* fix: checklist percentage complete
This commit is contained in:
Richard Shiue 2023-09-13 20:44:04 +08:00 committed by GitHub
parent 524efc2620
commit 0c6a1d4ae7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 747 additions and 394 deletions

View File

@ -437,4 +437,116 @@ void main() {
await tester.pumpAndSettle();
});
});
testWidgets('edit checklist cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
const fieldType = FieldType.Checklist;
await tester.createField(fieldType, fieldType.name);
// assert that there is no progress bar in the grid
tester.assertChecklistCellInGrid(rowIndex: 0, percent: null);
// tap on the first checklist cell
await tester.tapChecklistCellInGrid(rowIndex: 0);
// assert that the checklist editor is shown
tester.assertChecklistEditorVisible(visible: true);
// assert that new task editor is shown
tester.assertNewCheckListTaskEditorVisible(visible: true);
// create a new task with enter
await tester.createNewChecklistTask(name: "task 0", enter: true);
// assert that the task is displayed
tester.assertChecklistTaskInEditor(
index: 0,
name: "task 0",
isChecked: false,
);
// update the task's name
await tester.renameChecklistTask(index: 0, name: "task 1");
// assert that the task's name is updated
tester.assertChecklistTaskInEditor(
index: 0,
name: "task 1",
isChecked: false,
);
// dismiss new task editor
await tester.dismissCellEditor();
tester.assertNewCheckListTaskEditorVisible(visible: false);
// dismiss checklist cell editor
await tester.dismissCellEditor();
// assert that progress bar is shown in grid at 0%
tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0);
// start editing the first checklist cell again, click on new task button
await tester.tapChecklistCellInGrid(rowIndex: 0);
tester.assertNewCheckListTaskEditorVisible(visible: false);
await tester.tapChecklistNewTaskButton();
tester.assertNewCheckListTaskEditorVisible(visible: true);
// create another task with the create button
await tester.createNewChecklistTask(name: "task 2", button: true);
// assert that the task was inserted
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: false,
);
// mark it as complete
await tester.checkChecklistTask(index: 1);
// assert that the task was checked in the editor
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: true,
);
// dismiss checklist editor
await tester.dismissCellEditor();
await tester.dismissCellEditor();
// assert that progressbar is shown in grid at 50%
tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0.5);
// re-open the cell editor
await tester.tapChecklistCellInGrid(rowIndex: 0);
// hover over first task and delete it
await tester.deleteChecklistTask(index: 0);
// dismiss cell editor
await tester.dismissCellEditor();
// assert that progressbar is shown in grid at 100%
tester.assertChecklistCellInGrid(rowIndex: 0, percent: 1);
// re-open the cell edior
await tester.tapChecklistCellInGrid(rowIndex: 0);
// delete the remaining task
await tester.deleteChecklistTask(index: 0);
// assert that the new task editor is shown
tester.assertNewCheckListTaskEditorVisible(visible: true);
// dismiss the cell editor
await tester.dismissCellEditor();
// check that the progress bar is not viisble
tester.assertChecklistCellInGrid(rowIndex: 0, percent: null);
});
}

View File

@ -122,17 +122,17 @@ void main() {
}
// check the checklist cell
final List<double> checklistCells = [
0.6,
0.3,
final List<double?> checklistCells = [
0.67,
0.33,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
null,
null,
null,
null,
null,
null,
null,
];
for (final (index, percent) in checklistCells.indexed) {
await tester.assertChecklistCellInGrid(

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart';
@ -38,6 +39,7 @@ import 'package:appflowy/plugins/database_view/widgets/database_layout_ext.dart'
import 'package:appflowy/plugins/database_view/widgets/field/grid_property.dart';
import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_progress_bar.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
@ -243,23 +245,33 @@ extension AppFlowyDatabaseTest on WidgetTester {
}
}
/// null percent means no progress bar should be found
Future<void> assertChecklistCellInGrid({
required int rowIndex,
required double percent,
required double? percent,
}) async {
final findCell = cellFinder(rowIndex, FieldType.Checklist);
final finder = find.descendant(
of: findCell,
matching: find.byWidgetPredicate(
(widget) {
if (widget is ChecklistProgressBar) {
return widget.percent == percent;
}
return false;
},
),
);
expect(finder, findsOneWidget);
if (percent == null) {
final finder = find.descendant(
of: findCell,
matching: find.byType(ChecklistProgressBar),
);
expect(finder, findsNothing);
} else {
final finder = find.descendant(
of: findCell,
matching: find.byWidgetPredicate(
(widget) {
if (widget is ChecklistProgressBar) {
return widget.percent == percent;
}
return false;
},
),
);
expect(finder, findsOneWidget);
}
}
Future<void> assertDateCellInGrid({
@ -450,6 +462,119 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(cell, matcher);
}
Future<void> tapChecklistCellInGrid({required int rowIndex}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(FieldType.Checklist);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
await tapButton(cell);
}
void assertChecklistEditorVisible({required bool visible}) {
final editor = find.byType(GridChecklistCellEditor);
if (visible) {
expect(editor, findsOneWidget);
} else {
expect(editor, findsNothing);
}
}
void assertNewCheckListTaskEditorVisible({required bool visible}) {
final editor = find.byType(NewTaskItem);
if (visible) {
expect(editor, findsOneWidget);
} else {
expect(editor, findsNothing);
}
}
Future<void> createNewChecklistTask({
required String name,
enter = false,
button = false,
}) async {
assert(!(enter && button));
final textField = find.descendant(
of: find.byType(NewTaskItem),
matching: find.byType(TextField),
);
await enterText(textField, name);
await pumpAndSettle(const Duration(milliseconds: 300));
if (enter) {
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle(const Duration(milliseconds: 300));
} else {
await tapButton(
find.descendant(
of: find.byType(NewTaskItem),
matching: find.byType(FlowyTextButton),
),
);
}
}
void assertChecklistTaskInEditor({
required int index,
required String name,
required bool isChecked,
}) {
final task = find.byType(ChecklistItem).at(index);
final widget = this.widget<ChecklistItem>(task);
assert(
widget.option.data.name == name && widget.option.isSelected == isChecked,
);
}
Future<void> renameChecklistTask({
required int index,
required String name,
}) async {
final textField = find
.descendant(
of: find.byType(ChecklistItem),
matching: find.byType(TextField),
)
.at(index);
await enterText(textField, name);
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle(const Duration(milliseconds: 300));
}
Future<void> tapChecklistNewTaskButton() async {
await tapButton(find.byType(ChecklistNewTaskButton));
}
Future<void> checkChecklistTask({required int index}) async {
final button = find.descendant(
of: find.byType(ChecklistItem).at(index),
matching: find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.uncheck_s,
),
);
await tapButton(button);
}
Future<void> deleteChecklistTask({required int index}) async {
final task = find.byType(ChecklistItem).at(index);
await startGesture(getCenter(task), kind: PointerDeviceKind.mouse);
await pumpAndSettle();
final button = find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s,
);
await tapButton(button);
}
Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid();

View File

@ -4,6 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:protobuf/protobuf.dart';
class ChecklistCellBackendService {
final String viewId;
@ -52,14 +53,19 @@ class ChecklistCellBackendService {
return DatabaseEventUpdateChecklistCell(payload).send();
}
Future<Either<Unit, FlowyError>> update({
Future<Either<Unit, FlowyError>> updateName({
required SelectOptionPB option,
required name,
}) {
option.freeze();
final newOption = option.rebuild((option) {
option.name = name;
});
final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..updateOptions.add(option);
..updateOptions.add(newOption);
return DatabaseEventUpdateChecklistCell(payload).send();
}

View File

@ -32,10 +32,16 @@ class _ChecklistCardCellState extends State<ChecklistCardCell> {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>(
builder: (context, state) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ChecklistProgressBar(percent: state.percent),
),
builder: (context, state) {
if (state.allOptions.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ChecklistProgressBar(percent: state.percent),
);
},
),
);
}

View File

@ -40,7 +40,7 @@ class GridChecklistCellState extends GridCellState<GridChecklistCell> {
child: AppFlowyPopover(
margin: EdgeInsets.zero,
controller: _popover,
constraints: BoxConstraints.loose(const Size(260, 400)),
constraints: BoxConstraints.loose(const Size(360, 400)),
direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
popupBuilder: (BuildContext context) {
@ -56,8 +56,12 @@ class GridChecklistCellState extends GridCellState<GridChecklistCell> {
child: Padding(
padding: GridSize.cellContentInsets,
child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>(
builder: (context, state) =>
ChecklistProgressBar(percent: state.percent),
builder: (context, state) {
if (state.allOptions.isEmpty) {
return const SizedBox.shrink();
}
return ChecklistProgressBar(percent: state.percent);
},
),
),
),

View File

@ -29,13 +29,23 @@ class ChecklistCardCellBloc
_loadOptions();
},
didReceiveOptions: (data) {
emit(
state.copyWith(
allOptions: data.options,
selectedOptions: data.selectedOptions,
percent: data.percentage,
),
);
if (data == null) {
emit(
const ChecklistCellState(
allOptions: [],
selectedOptions: [],
percent: 0,
),
);
} else {
emit(
state.copyWith(
allOptions: data.options,
selectedOptions: data.selectedOptions,
percent: data.percentage,
),
);
}
},
);
},
@ -58,7 +68,7 @@ class ChecklistCardCellBloc
_loadOptions();
},
onCellChanged: (data) {
if (!isClosed && data != null) {
if (!isClosed) {
add(ChecklistCellEvent.didReceiveOptions(data));
}
},
@ -81,7 +91,7 @@ class ChecklistCardCellBloc
class ChecklistCellEvent with _$ChecklistCellEvent {
const factory ChecklistCellEvent.initial() = _InitialCell;
const factory ChecklistCellEvent.didReceiveOptions(
ChecklistCellDataPB data,
ChecklistCellDataPB? data,
) = _DidReceiveCellUpdate;
}

View File

@ -1,21 +1,23 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart';
import 'package:collection/collection.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/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../../../grid/presentation/widgets/header/type_option/select_option_editor.dart';
import 'checklist_cell_editor_bloc.dart';
import 'checklist_progress_bar.dart';
class GridChecklistCellEditor extends StatefulWidget {
final ChecklistCellController cellController;
const GridChecklistCellEditor({required this.cellController, Key? key})
: super(key: key);
const GridChecklistCellEditor({required this.cellController, super.key});
@override
State<GridChecklistCellEditor> createState() =>
@ -23,167 +25,367 @@ class GridChecklistCellEditor extends StatefulWidget {
}
class _GridChecklistCellEditorState extends State<GridChecklistCellEditor> {
late ChecklistCellEditorBloc bloc;
late PopoverMutex popoverMutex;
late ChecklistCellEditorBloc _bloc;
/// Focus node for the new task text field
late final FocusNode newTaskFocusNode;
/// A flag that determines whether the new task text field is visible
bool _isAddingNewTask = false;
@override
void initState() {
popoverMutex = PopoverMutex();
bloc = ChecklistCellEditorBloc(cellController: widget.cellController);
bloc.add(const ChecklistCellEditorEvent.initial());
super.initState();
}
@override
void dispose() {
bloc.close();
super.dispose();
newTaskFocusNode = FocusNode();
_bloc = ChecklistCellEditorBloc(cellController: widget.cellController)
..add(const ChecklistCellEditorEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<ChecklistCellEditorBloc, ChecklistCellEditorState>(
value: _bloc,
child: BlocConsumer<ChecklistCellEditorBloc, ChecklistCellEditorState>(
listener: (context, state) {
if (state.allOptions.isEmpty) {
setState(() => _isAddingNewTask = true);
}
},
builder: (context, state) {
final List<Widget> slivers = [
const SliverChecklistProgressBar(),
SliverToBoxAdapter(
child: ListView.separated(
controller: ScrollController(),
shrinkWrap: true,
itemCount: state.allOptions.length,
itemBuilder: (BuildContext context, int index) {
return _ChecklistOptionCell(
option: state.allOptions[index],
popoverMutex: popoverMutex,
);
},
separatorBuilder: (BuildContext context, int index) {
return VSpace(GridSize.typeOptionSeparatorHeight);
},
),
),
];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(scrollbars: false),
child: CustomScrollView(
shrinkWrap: true,
slivers: slivers,
controller: ScrollController(),
physics: StyledScrollPhysics(),
),
return Focus(
onKey: (node, event) {
// don't hide new task text field if there are no tasks at all
if (state.allOptions.isNotEmpty &&
event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
setState(() {
_isAddingNewTask = false;
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: CustomScrollView(
shrinkWrap: true,
physics: StyledScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.allOptions.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: ChecklistProgressBar(
percent: state.percent,
),
),
),
),
ChecklistItemList(
options: state.allOptions,
newTaskFocusNode: newTaskFocusNode,
isAddingNewTask: _isAddingNewTask,
onUpdateTask: () => setState(() {
_isAddingNewTask = true;
newTaskFocusNode.requestFocus();
}),
),
const SliverToBoxAdapter(
child: TypeOptionSeparator(spacing: 0.0),
),
SliverToBoxAdapter(
child: ChecklistNewTaskButton(
onTap: () => setState(() => _isAddingNewTask = true),
),
),
],
),
);
},
),
);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
}
class _ChecklistOptionCell extends StatefulWidget {
/// Displays the a list of all the exisiting tasks and an input field to create
/// a new task if `isAddingNewTask` is true
class ChecklistItemList extends StatefulWidget {
final List<ChecklistSelectOption> options;
final FocusNode newTaskFocusNode;
final bool isAddingNewTask;
final VoidCallback onUpdateTask;
const ChecklistItemList({
super.key,
required this.options,
required this.onUpdateTask,
required this.isAddingNewTask,
required this.newTaskFocusNode,
});
@override
State<ChecklistItemList> createState() => _ChecklistItemListState();
}
class _ChecklistItemListState extends State<ChecklistItemList> {
@override
Widget build(BuildContext context) {
final itemList = [
const VSpace(6.0),
...widget.options.mapIndexed(
(index, option) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ChecklistItem(
option: option,
onSubmitted:
index == widget.options.length - 1 ? widget.onUpdateTask : null,
key: ValueKey(option.data.id),
// only allow calling the callback for the last task in the list
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: widget.isAddingNewTask
? NewTaskItem(focusNode: widget.newTaskFocusNode)
: const SizedBox.shrink(),
),
const VSpace(6.0),
];
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => itemList[index],
childCount: itemList.length,
),
);
}
}
/// Represents an existing task
@visibleForTesting
class ChecklistItem extends StatefulWidget {
final ChecklistSelectOption option;
final PopoverMutex popoverMutex;
const _ChecklistOptionCell({
final VoidCallback? onSubmitted;
const ChecklistItem({
required this.option,
required this.popoverMutex,
Key? key,
this.onSubmitted,
}) : super(key: key);
@override
State<_ChecklistOptionCell> createState() => _ChecklistOptionCellState();
State<ChecklistItem> createState() => _ChecklistItemState();
}
class _ChecklistOptionCellState extends State<_ChecklistOptionCell> {
late PopoverController _popoverController;
class _ChecklistItemState extends State<ChecklistItem> {
late final TextEditingController _textController;
late final FocusNode _focusNode;
bool _isHovered = false;
@override
void initState() {
_popoverController = PopoverController();
super.initState();
_textController = TextEditingController(text: widget.option.data.name);
_focusNode = FocusNode(
onKey: (node, event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
}
@override
Widget build(BuildContext context) {
final icon = widget.option.isSelected
? const FlowySvg(
FlowySvgs.check_filled_s,
blendMode: BlendMode.dst,
)
: const FlowySvg(FlowySvgs.uncheck_s);
return _wrapPopover(
SizedBox(
height: GridSize.popoverItemHeight,
child: Row(
children: [
Expanded(
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText(
widget.option.data.name,
color: AFThemeExtension.of(context).textColor,
),
leftIcon: icon,
onTap: () => context
.read<ChecklistCellEditorBloc>()
.add(ChecklistCellEditorEvent.selectOption(widget.option)),
final icon = FlowySvg(
widget.option.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
);
return MouseRegion(
onEnter: (event) => setState(() => _isHovered = true),
onExit: (event) => setState(() => _isHovered = false),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
child: DecoratedBox(
decoration: BoxDecoration(
color: _isHovered
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FlowyIconButton(
width: 32,
icon: icon,
hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.selectTask(widget.option.data),
),
),
),
_disclosureButton(),
],
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 2.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onSubmitted: (taskDescription) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.updateTaskName(
widget.option.data,
taskDescription,
),
);
widget.onSubmitted?.call();
},
),
),
if (_isHovered)
FlowyIconButton(
width: 32,
icon: const FlowySvg(FlowySvgs.delete_s),
hoverColor: Colors.transparent,
iconColorOnHover: Theme.of(context).colorScheme.error,
onPressed: () => context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.deleteTask(widget.option.data),
),
),
],
),
),
),
);
}
}
Widget _disclosureButton() {
return FlowyIconButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
width: 20,
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2),
icon: FlowySvg(
FlowySvgs.details_s,
color: Theme.of(context).iconTheme.color,
/// Creates a new task after entering the description and pressing enter.
/// This can be cancelled by pressing escape
@visibleForTesting
class NewTaskItem extends StatefulWidget {
final FocusNode focusNode;
const NewTaskItem({super.key, required this.focusNode});
@override
State<NewTaskItem> createState() => _NewTaskItemState();
}
class _NewTaskItemState extends State<NewTaskItem> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
if (widget.focusNode.canRequestFocus) {
widget.focusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const FlowyIconButton(
width: 32,
icon: FlowySvg(
FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
),
hoverColor: Colors.transparent,
),
Expanded(
child: TextField(
focusNode: widget.focusNode,
controller: _textEditingController,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 2.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onSubmitted: (taskDescription) {
if (taskDescription.trim().isNotEmpty) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.newTask(
taskDescription.trim(),
),
);
}
_textEditingController.clear();
},
),
),
FlowyTextButton(
LocaleKeys.grid_checklist_submitNewTask.tr(),
fontSize: 11,
fillColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primaryContainer,
fontColor: Theme.of(context).colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
onPressed: () {
if (_textEditingController.text.trim().isNotEmpty) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.newTask(
_textEditingController.text..trim(),
),
);
}
_textEditingController.clear();
},
),
],
),
);
}
}
Widget _wrapPopover(Widget child) {
return AppFlowyPopover(
controller: _popoverController,
offset: const Offset(8, 0),
asBarrier: true,
constraints: BoxConstraints.loose(const Size(200, 300)),
mutex: widget.popoverMutex,
triggerActions: PopoverTriggerFlags.none,
child: child,
popupBuilder: (BuildContext popoverContext) {
return SelectOptionTypeOptionEditor(
option: widget.option.data,
onDeleted: () {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.deleteOption(widget.option.data),
);
@visibleForTesting
class ChecklistNewTaskButton extends StatelessWidget {
final VoidCallback onTap;
const ChecklistNewTaskButton({super.key, required this.onTap});
_popoverController.close();
},
onUpdated: (updatedOption) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.updateOption(updatedOption),
);
},
showOptions: false,
autoFocus: false,
// Use ValueKey to refresh the UI, otherwise, it will remain the old value.
key: ValueKey(
widget.option.data.id,
),
);
},
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: SizedBox(
height: 30,
child: FlowyButton(
text: FlowyText.medium(LocaleKeys.grid_checklist_addNew.tr()),
margin: const EdgeInsets.all(6),
leftIcon: const FlowySvg(FlowySvgs.add_s),
onTap: onTap,
),
),
);
}
}

View File

@ -11,6 +11,13 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'checklist_cell_editor_bloc.freezed.dart';
class ChecklistSelectOption {
final bool isSelected;
final SelectOptionPB data;
ChecklistSelectOption(this.isSelected, this.data);
}
class ChecklistCellEditorBloc
extends Bloc<ChecklistCellEditorEvent, ChecklistCellEditorState> {
final ChecklistCellBackendService _checklistCellService;
@ -31,33 +38,31 @@ class ChecklistCellEditorBloc
_startListening();
_loadOptions();
},
didReceiveOptions: (data) {
didReceiveTasks: (data) {
emit(
state.copyWith(
allOptions: _makeChecklistSelectOptions(data, state.predicate),
percent: data.percentage,
allOptions: _makeChecklistSelectOptions(data),
percent: data?.percentage ?? 0,
),
);
},
newOption: (optionName) {
_createOption(optionName);
newTask: (optionName) async {
await _createOption(optionName);
emit(
state.copyWith(
createOption: Some(optionName),
predicate: '',
),
);
},
deleteOption: (option) {
_deleteOption([option]);
deleteTask: (option) async {
await _deleteOption([option]);
},
updateOption: (option) {
_updateOption(option);
updateTaskName: (option, name) {
_updateOption(option, name);
},
selectOption: (option) async {
await _checklistCellService.select(optionId: option.data.id);
selectTask: (option) async {
await _checklistCellService.select(optionId: option.id);
},
filterOption: (String predicate) {},
);
},
);
@ -69,22 +74,21 @@ class ChecklistCellEditorBloc
return super.close();
}
void _createOption(String name) async {
Future<void> _createOption(String name) async {
final result = await _checklistCellService.create(name: name);
result.fold((l) => {}, (err) => Log.error(err));
}
void _deleteOption(List<SelectOptionPB> options) async {
Future<void> _deleteOption(List<SelectOptionPB> options) async {
final result = await _checklistCellService.delete(
optionIds: options.map((e) => e.id).toList(),
);
result.fold((l) => null, (err) => Log.error(err));
}
void _updateOption(SelectOptionPB option) async {
final result = await _checklistCellService.update(
option: option,
);
void _updateOption(SelectOptionPB option, String name) async {
final result =
await _checklistCellService.updateName(option: option, name: name);
result.fold((l) => null, (err) => Log.error(err));
}
@ -94,7 +98,7 @@ class ChecklistCellEditorBloc
if (isClosed) return;
return result.fold(
(data) => add(ChecklistCellEditorEvent.didReceiveOptions(data)),
(data) => add(ChecklistCellEditorEvent.didReceiveTasks(data)),
(err) => Log.error(err),
);
});
@ -103,8 +107,8 @@ class ChecklistCellEditorBloc
void _startListening() {
cellController.startListening(
onCellChanged: ((data) {
if (!isClosed && data != null) {
add(ChecklistCellEditorEvent.didReceiveOptions(data));
if (!isClosed) {
add(ChecklistCellEditorEvent.didReceiveTasks(data));
}
}),
onCellFieldChanged: () {
@ -117,20 +121,19 @@ class ChecklistCellEditorBloc
@freezed
class ChecklistCellEditorEvent with _$ChecklistCellEditorEvent {
const factory ChecklistCellEditorEvent.initial() = _Initial;
const factory ChecklistCellEditorEvent.didReceiveOptions(
ChecklistCellDataPB data,
) = _DidReceiveOptions;
const factory ChecklistCellEditorEvent.newOption(String optionName) =
_NewOption;
const factory ChecklistCellEditorEvent.selectOption(
ChecklistSelectOption option,
) = _SelectOption;
const factory ChecklistCellEditorEvent.updateOption(SelectOptionPB option) =
_UpdateOption;
const factory ChecklistCellEditorEvent.deleteOption(SelectOptionPB option) =
_DeleteOption;
const factory ChecklistCellEditorEvent.filterOption(String predicate) =
_FilterOption;
const factory ChecklistCellEditorEvent.didReceiveTasks(
ChecklistCellDataPB? data,
) = _DidReceiveTasks;
const factory ChecklistCellEditorEvent.newTask(String taskName) = _NewOption;
const factory ChecklistCellEditorEvent.selectTask(
SelectOptionPB option,
) = _SelectTask;
const factory ChecklistCellEditorEvent.updateTaskName(
SelectOptionPB option,
String name,
) = _UpdateTaskName;
const factory ChecklistCellEditorEvent.deleteTask(SelectOptionPB option) =
_DeleteTask;
}
@freezed
@ -139,24 +142,21 @@ class ChecklistCellEditorState with _$ChecklistCellEditorState {
required List<ChecklistSelectOption> allOptions,
required Option<String> createOption,
required double percent,
required String predicate,
}) = _ChecklistCellEditorState;
factory ChecklistCellEditorState.initial(ChecklistCellController context) {
final data = context.getCellData(loadIfNotExist: true);
return ChecklistCellEditorState(
allOptions: _makeChecklistSelectOptions(data, ''),
allOptions: _makeChecklistSelectOptions(data),
createOption: none(),
percent: data?.percentage ?? 0,
predicate: '',
);
}
}
List<ChecklistSelectOption> _makeChecklistSelectOptions(
ChecklistCellDataPB? data,
String predicate,
) {
if (data == null) {
return [];
@ -164,9 +164,6 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
final List<ChecklistSelectOption> options = [];
final List<SelectOptionPB> allOptions = List.from(data.options);
if (predicate.isNotEmpty) {
allOptions.retainWhere((element) => element.name.contains(predicate));
}
final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList();
for (final option in allOptions) {
@ -177,10 +174,3 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
return options;
}
class ChecklistSelectOption {
final bool isSelected;
final SelectOptionPB data;
ChecklistSelectOption(this.isSelected, this.data);
}

View File

@ -1,129 +1,44 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:percent_indicator/percent_indicator.dart';
class ChecklistProgressBar extends StatelessWidget {
class ChecklistProgressBar extends StatefulWidget {
final double percent;
const ChecklistProgressBar({required this.percent, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return LinearPercentIndicator(
lineHeight: 10.0,
percent: percent,
padding: EdgeInsets.zero,
progressColor: percent < 1.0
? SelectOptionColorPB.Purple.toColor(context)
: SelectOptionColorPB.Green.toColor(context),
backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(5),
);
}
State<ChecklistProgressBar> createState() => _ChecklistProgressBarState();
}
class SliverChecklistProgressBar extends StatelessWidget {
const SliverChecklistProgressBar({Key? key}) : super(key: key);
class _ChecklistProgressBarState extends State<ChecklistProgressBar> {
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverChecklistProgressBarDelegate(),
);
}
}
class _SliverChecklistProgressBarDelegate
extends SliverPersistentHeaderDelegate {
_SliverChecklistProgressBarDelegate();
double fixHeight = 60;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return const _AutoFocusTextField();
}
@override
double get maxExtent => fixHeight;
@override
double get minExtent => fixHeight;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return true;
}
}
class _AutoFocusTextField extends StatefulWidget {
const _AutoFocusTextField();
@override
State<_AutoFocusTextField> createState() => _AutoFocusTextFieldState();
}
class _AutoFocusTextFieldState extends State<_AutoFocusTextField> {
final _focusNode = FocusNode();
@override
Widget build(BuildContext context) {
return BlocBuilder<ChecklistCellEditorBloc, ChecklistCellEditorState>(
builder: (context, state) {
return BlocListener<ChecklistCellEditorBloc, ChecklistCellEditorState>(
listenWhen: (previous, current) =>
previous.createOption != current.createOption,
listener: (context, state) {
if (_focusNode.canRequestFocus) {
_focusNode.requestFocus();
}
},
child: Container(
color: Theme.of(context).cardColor,
child: Padding(
padding: GridSize.typeOptionContentInsets,
child: Column(
children: [
FlowyTextField(
autoFocus: true,
focusNode: _focusNode,
autoClearWhenDone: true,
submitOnLeave: true,
hintText: LocaleKeys.grid_checklist_panelTitle.tr(),
onChanged: (text) {
context
.read<ChecklistCellEditorBloc>()
.add(ChecklistCellEditorEvent.filterOption(text));
},
onSubmitted: (text) {
context
.read<ChecklistCellEditorBloc>()
.add(ChecklistCellEditorEvent.newOption(text));
},
),
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: ChecklistProgressBar(percent: state.percent),
),
],
),
return Row(
children: [
Expanded(
child: LinearPercentIndicator(
lineHeight: 4.0,
percent: widget.percent,
padding: EdgeInsets.zero,
progressColor: Theme.of(context).colorScheme.primary,
backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(5),
),
),
SizedBox(
width: 36,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: FlowyText.regular(
"${(widget.percent * 100).round()}%",
fontSize: 11,
color: Theme.of(context).hintColor,
),
),
);
},
),
],
);
}
}

View File

@ -2,7 +2,6 @@ import 'dart:collection';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef EntryMap = LinkedHashMap<PopoverState, OverlayEntryContext>;
@ -67,50 +66,19 @@ class OverlayEntryContext {
);
}
class PopoverMask extends StatefulWidget {
class PopoverMask extends StatelessWidget {
final void Function() onTap;
final void Function()? onExit;
final Decoration? decoration;
const PopoverMask(
{Key? key, required this.onTap, this.onExit, this.decoration})
const PopoverMask({Key? key, required this.onTap, this.decoration})
: super(key: key);
@override
State<StatefulWidget> createState() => _PopoverMaskState();
}
class _PopoverMaskState extends State<PopoverMask> {
@override
void initState() {
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
super.initState();
}
bool _handleGlobalKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.escape &&
event is KeyDownEvent) {
if (widget.onExit != null) {
widget.onExit!();
}
return true;
} else {
return false;
}
}
@override
void deactivate() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
super.deactivate();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onTap: onTap,
child: Container(
decoration: widget.decoration,
decoration: decoration,
),
);
}

View File

@ -1,5 +1,6 @@
import 'package:appflowy_popover/src/layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'mask.dart';
import 'mutex.dart';
@ -130,7 +131,6 @@ class PopoverState extends State<Popover> {
}
_removeRootOverlay();
},
onExit: () => _removeRootOverlay(),
),
);
}
@ -147,7 +147,17 @@ class PopoverState extends State<Popover> {
),
);
return Stack(children: children);
return FocusScope(
onKey: (node, event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
_removeRootOverlay();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Stack(children: children),
);
});
_rootEntry.addEntry(context, this, newEntry, widget.asBarrier);
}
@ -192,9 +202,9 @@ class PopoverState extends State<Popover> {
showOverlay();
}
},
child: Listener(
child: GestureDetector(
child: widget.child,
onPointerDown: (PointerDownEvent event) {
onTap: () {
if (widget.triggerActions & PopoverTriggerFlags.click != 0) {
showOverlay();
}
@ -240,14 +250,17 @@ class PopoverContainer extends StatefulWidget {
class PopoverContainerState extends State<PopoverContainer> {
@override
Widget build(BuildContext context) {
return CustomSingleChildLayout(
delegate: PopoverLayoutDelegate(
direction: widget.direction,
link: widget.popoverLink,
offset: widget.offset,
windowPadding: widget.windowPadding,
return Focus(
autofocus: true,
child: CustomSingleChildLayout(
delegate: PopoverLayoutDelegate(
direction: widget.direction,
link: widget.popoverLink,
offset: widget.offset,
windowPadding: widget.windowPadding,
),
child: widget.popupBuilder(context),
),
child: widget.popupBuilder(context),
);
}

View File

@ -386,7 +386,7 @@
"searchOption": "ابحث عن خيار"
},
"checklist": {
"panelTitle": "أضف عنصرًا"
"addNew": "أضف عنصرًا"
},
"menuName": "شبكة",
"referencedGridPrefix": "نظرا ل",

View File

@ -402,7 +402,7 @@
"searchOption": "Cerca una opció"
},
"checklist": {
"panelTitle": "Afegeix un element"
"addNew": "Afegeix un element"
},
"menuName": "Quadrícula",
"referencedGridPrefix": "Vista de"

View File

@ -411,7 +411,7 @@
"searchOption": "Suchen Sie nach einer Option"
},
"checklist": {
"panelTitle": "Fügen Sie einen Artikel hinzu"
"addNew": "Fügen Sie einen Artikel hinzu"
},
"menuName": "Netz",
"referencedGridPrefix": "Sicht von"

View File

@ -477,7 +477,9 @@
"orSelectOne": "Or select an option"
},
"checklist": {
"panelTitle": "Add an item"
"taskHint": "Task description",
"addNew": "Add a new task",
"submitNewTask": "Create"
},
"menuName": "Grid",
"referencedGridPrefix": "View of"

View File

@ -377,7 +377,7 @@
"addSort": "Agregar clasificación"
},
"checklist": {
"panelTitle": "Agregar un elemento"
"addNew": "Agregar un elemento"
},
"referencedGridPrefix": "Vista de"
},

View File

@ -387,7 +387,7 @@
"searchOption": "Aukera bat bilatu"
},
"checklist": {
"panelTitle": "Gehitu elementu bat"
"addNew": "Gehitu elementu bat"
},
"menuName": "Sareta",
"deleteView": "Ziur ikuspegi hau ezabatu nahi duzula?",

View File

@ -439,7 +439,7 @@
"searchOption": "جستجوی یک گزینه"
},
"checklist": {
"panelTitle": "یک مورد اضافه کنید"
"addNew": "یک مورد اضافه کنید"
},
"menuName": "شبکه‌ای",
"referencedGridPrefix": "نمایش"

View File

@ -402,7 +402,7 @@
"searchOption": "Rechercher une option"
},
"checklist": {
"panelTitle": "Ajouter un article"
"addNew": "Ajouter un article"
},
"menuName": "Grille",
"referencedGridPrefix": "Vue"

View File

@ -381,7 +381,7 @@
"addSort": "Ajouter un tri"
},
"checklist": {
"panelTitle": "Ajouter un élément"
"addNew": "Ajouter un élément"
},
"referencedGridPrefix": "Vue"
},

View File

@ -402,7 +402,7 @@
"searchOption": "Keressen egy lehetőséget"
},
"checklist": {
"panelTitle": "Adjon hozzá egy elemet"
"addNew": "Adjon hozzá egy elemet"
},
"menuName": "Rács",
"referencedGridPrefix": "Nézet"

View File

@ -377,7 +377,7 @@
"addSort": "Tambahkan semacam"
},
"checklist": {
"panelTitle": "Tambahkan item"
"addNew": "Tambahkan item"
},
"referencedGridPrefix": "Pemandangan dari"
},

View File

@ -370,7 +370,7 @@
"searchOption": "Cerca un'opzione"
},
"checklist": {
"panelTitle": "Aggiungi un elemento"
"addNew": "Aggiungi un elemento"
},
"referencedGridPrefix": "Vista di"
},

View File

@ -370,7 +370,7 @@
"addSort": "並べ替えの追加"
},
"checklist": {
"panelTitle": "アイテムを追加する"
"addNew": "アイテムを追加する"
},
"menuName": "グリッド",
"referencedGridPrefix": "のビュー"

View File

@ -383,7 +383,7 @@
"addSort": "정렬 추가"
},
"checklist": {
"panelTitle": "항목 추가"
"addNew": "항목 추가"
},
"referencedGridPrefix": "관점"
},

View File

@ -402,7 +402,7 @@
"searchOption": "Wyszukaj opcję"
},
"checklist": {
"panelTitle": "Dodaj element"
"addNew": "Dodaj element"
},
"menuName": "Siatka",
"referencedGridPrefix": "Widok"

View File

@ -438,7 +438,7 @@
"panelTitle": "Selecione uma opção ou crie uma"
},
"checklist": {
"panelTitle": "Adicionar um item"
"addNew": "Adicionar um item"
},
"menuName": "Grade",
"deleteView": "Tem certeza de que deseja excluir esta visualização?",

View File

@ -455,7 +455,7 @@
"searchOption": "Pesquise uma opção"
},
"checklist": {
"panelTitle": "Adicionar um item"
"addNew": "Adicionar um item"
},
"menuName": "Grade",
"referencedGridPrefix": "Vista de"

View File

@ -393,7 +393,7 @@
"searchOption": "Поиск"
},
"checklist": {
"panelTitle": "Добавить элемент"
"addNew": "Добавить элемент"
},
"menuName": "Сетка",
"referencedGridPrefix": "Просмотр",

View File

@ -381,7 +381,7 @@
"addSort": "Lägg till sortering"
},
"checklist": {
"panelTitle": "Lägg till ett objekt"
"addNew": "Lägg till ett objekt"
},
"referencedGridPrefix": "Utsikt över"
},

View File

@ -402,7 +402,7 @@
"searchOption": "Bir seçenek arayın"
},
"checklist": {
"panelTitle": "öğe ekle"
"addNew": "öğe ekle"
},
"menuName": "Kafes",
"referencedGridPrefix": "görünümü"

View File

@ -397,7 +397,7 @@
"searchOption": "搜索标签"
},
"checklist": {
"panelTitle": "添加项"
"addNew": "添加项"
},
"menuName": "网格",
"referencedGridPrefix": "视图"

View File

@ -386,7 +386,7 @@
"searchOption": "搜尋選項"
},
"checklist": {
"panelTitle": "新增物件"
"addNew": "新增物件"
},
"menuName": "網格",
"deleteView": "您確定要刪除該視圖嗎?",

View File

@ -42,7 +42,7 @@ impl ChecklistCellData {
if total_options == 0 {
return 0.0;
}
((selected_options as f64) / (total_options as f64) * 10.0).trunc() / 10.0
((selected_options as f64) / (total_options as f64) * 100.0).round() / 100.0
}
pub fn from_options(options: Vec<String>) -> Self {

View File

@ -653,7 +653,7 @@ async fn update_checklist_cell_test() {
assert_eq!(cell.options.len(), 3);
assert_eq!(cell.selected_options.len(), 2);
assert_eq!(cell.percentage, 0.6);
assert_eq!(cell.percentage, 0.67);
}
// The number of groups should be 0 if there is no group by field in grid