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(); 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 // check the checklist cell
final List<double> checklistCells = [ final List<double?> checklistCells = [
0.6, 0.67,
0.3, 0.33,
1.0, 1.0,
0.0, null,
0.0, null,
0.0, null,
0.0, null,
0.0, null,
0.0, null,
0.0, null,
]; ];
for (final (index, percent) in checklistCells.indexed) { for (final (index, percent) in checklistCells.indexed) {
await tester.assertChecklistCellInGrid( await tester.assertChecklistCellInGrid(

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.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/field/grid_property.dart';
import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.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/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/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/date_cell/date_editor.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/extension.dart';
@ -243,23 +245,33 @@ extension AppFlowyDatabaseTest on WidgetTester {
} }
} }
/// null percent means no progress bar should be found
Future<void> assertChecklistCellInGrid({ Future<void> assertChecklistCellInGrid({
required int rowIndex, required int rowIndex,
required double percent, required double? percent,
}) async { }) async {
final findCell = cellFinder(rowIndex, FieldType.Checklist); final findCell = cellFinder(rowIndex, FieldType.Checklist);
final finder = find.descendant(
of: findCell, if (percent == null) {
matching: find.byWidgetPredicate( final finder = find.descendant(
(widget) { of: findCell,
if (widget is ChecklistProgressBar) { matching: find.byType(ChecklistProgressBar),
return widget.percent == percent; );
} expect(finder, findsNothing);
return false; } else {
}, final finder = find.descendant(
), of: findCell,
); matching: find.byWidgetPredicate(
expect(finder, findsOneWidget); (widget) {
if (widget is ChecklistProgressBar) {
return widget.percent == percent;
}
return false;
},
),
);
expect(finder, findsOneWidget);
}
} }
Future<void> assertDateCellInGrid({ Future<void> assertDateCellInGrid({
@ -450,6 +462,119 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(cell, matcher); 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 { Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid(); 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-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
import 'package:protobuf/protobuf.dart';
class ChecklistCellBackendService { class ChecklistCellBackendService {
final String viewId; final String viewId;
@ -52,14 +53,19 @@ class ChecklistCellBackendService {
return DatabaseEventUpdateChecklistCell(payload).send(); return DatabaseEventUpdateChecklistCell(payload).send();
} }
Future<Either<Unit, FlowyError>> update({ Future<Either<Unit, FlowyError>> updateName({
required SelectOptionPB option, required SelectOptionPB option,
required name,
}) { }) {
option.freeze();
final newOption = option.rebuild((option) {
option.name = name;
});
final payload = ChecklistCellDataChangesetPB.create() final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId ..viewId = viewId
..fieldId = fieldId ..fieldId = fieldId
..rowId = rowId ..rowId = rowId
..updateOptions.add(option); ..updateOptions.add(newOption);
return DatabaseEventUpdateChecklistCell(payload).send(); return DatabaseEventUpdateChecklistCell(payload).send();
} }

View File

@ -32,10 +32,16 @@ class _ChecklistCardCellState extends State<ChecklistCardCell> {
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>( child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>(
builder: (context, state) => Padding( builder: (context, state) {
padding: const EdgeInsets.symmetric(vertical: 4), if (state.allOptions.isEmpty) {
child: ChecklistProgressBar(percent: state.percent), 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( child: AppFlowyPopover(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
controller: _popover, controller: _popover,
constraints: BoxConstraints.loose(const Size(260, 400)), constraints: BoxConstraints.loose(const Size(360, 400)),
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none, triggerActions: PopoverTriggerFlags.none,
popupBuilder: (BuildContext context) { popupBuilder: (BuildContext context) {
@ -56,8 +56,12 @@ class GridChecklistCellState extends GridCellState<GridChecklistCell> {
child: Padding( child: Padding(
padding: GridSize.cellContentInsets, padding: GridSize.cellContentInsets,
child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>( child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>(
builder: (context, state) => builder: (context, state) {
ChecklistProgressBar(percent: state.percent), if (state.allOptions.isEmpty) {
return const SizedBox.shrink();
}
return ChecklistProgressBar(percent: state.percent);
},
), ),
), ),
), ),

View File

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

View File

@ -1,21 +1,23 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; 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/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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.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_cell_editor_bloc.dart';
import 'checklist_progress_bar.dart'; import 'checklist_progress_bar.dart';
class GridChecklistCellEditor extends StatefulWidget { class GridChecklistCellEditor extends StatefulWidget {
final ChecklistCellController cellController; final ChecklistCellController cellController;
const GridChecklistCellEditor({required this.cellController, Key? key}) const GridChecklistCellEditor({required this.cellController, super.key});
: super(key: key);
@override @override
State<GridChecklistCellEditor> createState() => State<GridChecklistCellEditor> createState() =>
@ -23,167 +25,367 @@ class GridChecklistCellEditor extends StatefulWidget {
} }
class _GridChecklistCellEditorState extends State<GridChecklistCellEditor> { class _GridChecklistCellEditorState extends State<GridChecklistCellEditor> {
late ChecklistCellEditorBloc bloc; late ChecklistCellEditorBloc _bloc;
late PopoverMutex popoverMutex;
/// 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 @override
void initState() { void initState() {
popoverMutex = PopoverMutex();
bloc = ChecklistCellEditorBloc(cellController: widget.cellController);
bloc.add(const ChecklistCellEditorEvent.initial());
super.initState(); super.initState();
} newTaskFocusNode = FocusNode();
_bloc = ChecklistCellEditorBloc(cellController: widget.cellController)
@override ..add(const ChecklistCellEditorEvent.initial());
void dispose() {
bloc.close();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider.value( return BlocProvider.value(
value: bloc, value: _bloc,
child: BlocBuilder<ChecklistCellEditorBloc, ChecklistCellEditorState>( child: BlocConsumer<ChecklistCellEditorBloc, ChecklistCellEditorState>(
listener: (context, state) {
if (state.allOptions.isEmpty) {
setState(() => _isAddingNewTask = true);
}
},
builder: (context, state) { builder: (context, state) {
final List<Widget> slivers = [ return Focus(
const SliverChecklistProgressBar(), onKey: (node, event) {
SliverToBoxAdapter( // don't hide new task text field if there are no tasks at all
child: ListView.separated( if (state.allOptions.isNotEmpty &&
controller: ScrollController(), event is RawKeyDownEvent &&
shrinkWrap: true, event.logicalKey == LogicalKeyboardKey.escape) {
itemCount: state.allOptions.length, setState(() {
itemBuilder: (BuildContext context, int index) { _isAddingNewTask = false;
return _ChecklistOptionCell( });
option: state.allOptions[index], return KeyEventResult.handled;
popoverMutex: popoverMutex, }
); return KeyEventResult.ignored;
}, },
separatorBuilder: (BuildContext context, int index) { child: CustomScrollView(
return VSpace(GridSize.typeOptionSeparatorHeight); shrinkWrap: true,
}, physics: StyledScrollPhysics(),
), slivers: [
), SliverToBoxAdapter(
]; child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
return Padding( child: state.allOptions.isEmpty
padding: const EdgeInsets.all(8.0), ? const SizedBox.shrink()
child: ScrollConfiguration( : Padding(
behavior: const ScrollBehavior().copyWith(scrollbars: false), padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: CustomScrollView( child: ChecklistProgressBar(
shrinkWrap: true, percent: state.percent,
slivers: slivers, ),
controller: ScrollController(), ),
physics: StyledScrollPhysics(), ),
), ),
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 ChecklistSelectOption option;
final PopoverMutex popoverMutex; final VoidCallback? onSubmitted;
const _ChecklistOptionCell({ const ChecklistItem({
required this.option, required this.option,
required this.popoverMutex,
Key? key, Key? key,
this.onSubmitted,
}) : super(key: key); }) : super(key: key);
@override @override
State<_ChecklistOptionCell> createState() => _ChecklistOptionCellState(); State<ChecklistItem> createState() => _ChecklistItemState();
} }
class _ChecklistOptionCellState extends State<_ChecklistOptionCell> { class _ChecklistItemState extends State<ChecklistItem> {
late PopoverController _popoverController; late final TextEditingController _textController;
late final FocusNode _focusNode;
bool _isHovered = false;
@override @override
void initState() { void initState() {
_popoverController = PopoverController();
super.initState(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final icon = widget.option.isSelected final icon = FlowySvg(
? const FlowySvg( widget.option.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
FlowySvgs.check_filled_s, blendMode: BlendMode.dst,
blendMode: BlendMode.dst, );
) return MouseRegion(
: const FlowySvg(FlowySvgs.uncheck_s); onEnter: (event) => setState(() => _isHovered = true),
return _wrapPopover( onExit: (event) => setState(() => _isHovered = false),
SizedBox( child: Container(
height: GridSize.popoverItemHeight, padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row( constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
children: [ child: DecoratedBox(
Expanded( decoration: BoxDecoration(
child: FlowyButton( color: _isHovered
hoverColor: AFThemeExtension.of(context).lightGreyHover, ? AFThemeExtension.of(context).lightGreyHover
text: FlowyText( : Colors.transparent,
widget.option.data.name, borderRadius: Corners.s6Border,
color: AFThemeExtension.of(context).textColor, ),
), child: Row(
leftIcon: icon, crossAxisAlignment: CrossAxisAlignment.center,
onTap: () => context children: [
.read<ChecklistCellEditorBloc>() FlowyIconButton(
.add(ChecklistCellEditorEvent.selectOption(widget.option)), width: 32,
icon: icon,
hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.selectTask(widget.option.data),
),
), ),
), Expanded(
_disclosureButton(), 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() { /// Creates a new task after entering the description and pressing enter.
return FlowyIconButton( /// This can be cancelled by pressing escape
hoverColor: AFThemeExtension.of(context).lightGreyHover, @visibleForTesting
width: 20, class NewTaskItem extends StatefulWidget {
onPressed: () => _popoverController.show(), final FocusNode focusNode;
iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2), const NewTaskItem({super.key, required this.focusNode});
icon: FlowySvg(
FlowySvgs.details_s, @override
color: Theme.of(context).iconTheme.color, 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) { @visibleForTesting
return AppFlowyPopover( class ChecklistNewTaskButton extends StatelessWidget {
controller: _popoverController, final VoidCallback onTap;
offset: const Offset(8, 0), const ChecklistNewTaskButton({super.key, required this.onTap});
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),
);
_popoverController.close(); @override
}, Widget build(BuildContext context) {
onUpdated: (updatedOption) { return Padding(
context.read<ChecklistCellEditorBloc>().add( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
ChecklistCellEditorEvent.updateOption(updatedOption), child: SizedBox(
); height: 30,
}, child: FlowyButton(
showOptions: false, text: FlowyText.medium(LocaleKeys.grid_checklist_addNew.tr()),
autoFocus: false, margin: const EdgeInsets.all(6),
// Use ValueKey to refresh the UI, otherwise, it will remain the old value. leftIcon: const FlowySvg(FlowySvgs.add_s),
key: ValueKey( onTap: onTap,
widget.option.data.id, ),
), ),
);
},
); );
} }
} }

View File

@ -11,6 +11,13 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'checklist_cell_editor_bloc.freezed.dart'; part 'checklist_cell_editor_bloc.freezed.dart';
class ChecklistSelectOption {
final bool isSelected;
final SelectOptionPB data;
ChecklistSelectOption(this.isSelected, this.data);
}
class ChecklistCellEditorBloc class ChecklistCellEditorBloc
extends Bloc<ChecklistCellEditorEvent, ChecklistCellEditorState> { extends Bloc<ChecklistCellEditorEvent, ChecklistCellEditorState> {
final ChecklistCellBackendService _checklistCellService; final ChecklistCellBackendService _checklistCellService;
@ -31,33 +38,31 @@ class ChecklistCellEditorBloc
_startListening(); _startListening();
_loadOptions(); _loadOptions();
}, },
didReceiveOptions: (data) { didReceiveTasks: (data) {
emit( emit(
state.copyWith( state.copyWith(
allOptions: _makeChecklistSelectOptions(data, state.predicate), allOptions: _makeChecklistSelectOptions(data),
percent: data.percentage, percent: data?.percentage ?? 0,
), ),
); );
}, },
newOption: (optionName) { newTask: (optionName) async {
_createOption(optionName); await _createOption(optionName);
emit( emit(
state.copyWith( state.copyWith(
createOption: Some(optionName), createOption: Some(optionName),
predicate: '',
), ),
); );
}, },
deleteOption: (option) { deleteTask: (option) async {
_deleteOption([option]); await _deleteOption([option]);
}, },
updateOption: (option) { updateTaskName: (option, name) {
_updateOption(option); _updateOption(option, name);
}, },
selectOption: (option) async { selectTask: (option) async {
await _checklistCellService.select(optionId: option.data.id); await _checklistCellService.select(optionId: option.id);
}, },
filterOption: (String predicate) {},
); );
}, },
); );
@ -69,22 +74,21 @@ class ChecklistCellEditorBloc
return super.close(); return super.close();
} }
void _createOption(String name) async { Future<void> _createOption(String name) async {
final result = await _checklistCellService.create(name: name); final result = await _checklistCellService.create(name: name);
result.fold((l) => {}, (err) => Log.error(err)); 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( final result = await _checklistCellService.delete(
optionIds: options.map((e) => e.id).toList(), optionIds: options.map((e) => e.id).toList(),
); );
result.fold((l) => null, (err) => Log.error(err)); result.fold((l) => null, (err) => Log.error(err));
} }
void _updateOption(SelectOptionPB option) async { void _updateOption(SelectOptionPB option, String name) async {
final result = await _checklistCellService.update( final result =
option: option, await _checklistCellService.updateName(option: option, name: name);
);
result.fold((l) => null, (err) => Log.error(err)); result.fold((l) => null, (err) => Log.error(err));
} }
@ -94,7 +98,7 @@ class ChecklistCellEditorBloc
if (isClosed) return; if (isClosed) return;
return result.fold( return result.fold(
(data) => add(ChecklistCellEditorEvent.didReceiveOptions(data)), (data) => add(ChecklistCellEditorEvent.didReceiveTasks(data)),
(err) => Log.error(err), (err) => Log.error(err),
); );
}); });
@ -103,8 +107,8 @@ class ChecklistCellEditorBloc
void _startListening() { void _startListening() {
cellController.startListening( cellController.startListening(
onCellChanged: ((data) { onCellChanged: ((data) {
if (!isClosed && data != null) { if (!isClosed) {
add(ChecklistCellEditorEvent.didReceiveOptions(data)); add(ChecklistCellEditorEvent.didReceiveTasks(data));
} }
}), }),
onCellFieldChanged: () { onCellFieldChanged: () {
@ -117,20 +121,19 @@ class ChecklistCellEditorBloc
@freezed @freezed
class ChecklistCellEditorEvent with _$ChecklistCellEditorEvent { class ChecklistCellEditorEvent with _$ChecklistCellEditorEvent {
const factory ChecklistCellEditorEvent.initial() = _Initial; const factory ChecklistCellEditorEvent.initial() = _Initial;
const factory ChecklistCellEditorEvent.didReceiveOptions( const factory ChecklistCellEditorEvent.didReceiveTasks(
ChecklistCellDataPB data, ChecklistCellDataPB? data,
) = _DidReceiveOptions; ) = _DidReceiveTasks;
const factory ChecklistCellEditorEvent.newOption(String optionName) = const factory ChecklistCellEditorEvent.newTask(String taskName) = _NewOption;
_NewOption; const factory ChecklistCellEditorEvent.selectTask(
const factory ChecklistCellEditorEvent.selectOption( SelectOptionPB option,
ChecklistSelectOption option, ) = _SelectTask;
) = _SelectOption; const factory ChecklistCellEditorEvent.updateTaskName(
const factory ChecklistCellEditorEvent.updateOption(SelectOptionPB option) = SelectOptionPB option,
_UpdateOption; String name,
const factory ChecklistCellEditorEvent.deleteOption(SelectOptionPB option) = ) = _UpdateTaskName;
_DeleteOption; const factory ChecklistCellEditorEvent.deleteTask(SelectOptionPB option) =
const factory ChecklistCellEditorEvent.filterOption(String predicate) = _DeleteTask;
_FilterOption;
} }
@freezed @freezed
@ -139,24 +142,21 @@ class ChecklistCellEditorState with _$ChecklistCellEditorState {
required List<ChecklistSelectOption> allOptions, required List<ChecklistSelectOption> allOptions,
required Option<String> createOption, required Option<String> createOption,
required double percent, required double percent,
required String predicate,
}) = _ChecklistCellEditorState; }) = _ChecklistCellEditorState;
factory ChecklistCellEditorState.initial(ChecklistCellController context) { factory ChecklistCellEditorState.initial(ChecklistCellController context) {
final data = context.getCellData(loadIfNotExist: true); final data = context.getCellData(loadIfNotExist: true);
return ChecklistCellEditorState( return ChecklistCellEditorState(
allOptions: _makeChecklistSelectOptions(data, ''), allOptions: _makeChecklistSelectOptions(data),
createOption: none(), createOption: none(),
percent: data?.percentage ?? 0, percent: data?.percentage ?? 0,
predicate: '',
); );
} }
} }
List<ChecklistSelectOption> _makeChecklistSelectOptions( List<ChecklistSelectOption> _makeChecklistSelectOptions(
ChecklistCellDataPB? data, ChecklistCellDataPB? data,
String predicate,
) { ) {
if (data == null) { if (data == null) {
return []; return [];
@ -164,9 +164,6 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
final List<ChecklistSelectOption> options = []; final List<ChecklistSelectOption> options = [];
final List<SelectOptionPB> allOptions = List.from(data.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(); final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList();
for (final option in allOptions) { for (final option in allOptions) {
@ -177,10 +174,3 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
return options; 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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:percent_indicator/percent_indicator.dart'; import 'package:percent_indicator/percent_indicator.dart';
class ChecklistProgressBar extends StatelessWidget { class ChecklistProgressBar extends StatefulWidget {
final double percent; final double percent;
const ChecklistProgressBar({required this.percent, Key? key}) const ChecklistProgressBar({required this.percent, Key? key})
: super(key: key); : super(key: key);
@override @override
Widget build(BuildContext context) { State<ChecklistProgressBar> createState() => _ChecklistProgressBarState();
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),
);
}
} }
class SliverChecklistProgressBar extends StatelessWidget { class _ChecklistProgressBarState extends State<ChecklistProgressBar> {
const SliverChecklistProgressBar({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverPersistentHeader( return Row(
pinned: true, children: [
delegate: _SliverChecklistProgressBarDelegate(), Expanded(
); child: LinearPercentIndicator(
} lineHeight: 4.0,
} percent: widget.percent,
padding: EdgeInsets.zero,
class _SliverChecklistProgressBarDelegate progressColor: Theme.of(context).colorScheme.primary,
extends SliverPersistentHeaderDelegate { backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
_SliverChecklistProgressBarDelegate(); barRadius: const Radius.circular(5),
),
double fixHeight = 60; ),
SizedBox(
@override width: 36,
Widget build( child: Align(
BuildContext context, alignment: AlignmentDirectional.centerEnd,
double shrinkOffset, child: FlowyText.regular(
bool overlapsContent, "${(widget.percent * 100).round()}%",
) { fontSize: 11,
return const _AutoFocusTextField(); color: Theme.of(context).hintColor,
}
@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),
),
],
),
), ),
), ),
); ),
}, ],
); );
} }
} }

View File

@ -2,7 +2,6 @@ import 'dart:collection';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef EntryMap = LinkedHashMap<PopoverState, OverlayEntryContext>; 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() onTap;
final void Function()? onExit;
final Decoration? decoration; final Decoration? decoration;
const PopoverMask( const PopoverMask({Key? key, required this.onTap, this.decoration})
{Key? key, required this.onTap, this.onExit, this.decoration})
: super(key: key); : 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: widget.onTap, onTap: onTap,
child: Container( child: Container(
decoration: widget.decoration, decoration: decoration,
), ),
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,7 +42,7 @@ impl ChecklistCellData {
if total_options == 0 { if total_options == 0 {
return 0.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 { 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.options.len(), 3);
assert_eq!(cell.selected_options.len(), 2); 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 // The number of groups should be 0 if there is no group by field in grid