From b7c598ea567b34a6893c9da10e82a3905da15fab Mon Sep 17 00:00:00 2001 From: Mohammad Zolfaghari Date: Tue, 24 Sep 2024 17:33:07 +0330 Subject: [PATCH] feat(flutter_desktop): date filter (#6288) * feat: wip enabling date filter * fix: update date picker selected range on change * fix: save in utc, load in local date time * test: added date filter test * fix: don't include time in date picker including time makes filtering complex without much gain. when the condition is "is" we should also note the exact time which most of the cases the time is not intended by user only the day. * fix: flutter analyze --- .../database/database_filter_test.dart | 24 +- .../shared/database_test_op.dart | 14 + .../application/field/field_info.dart | 1 + .../database/domain/filter_service.dart | 9 +- .../filter/date_filter_editor_bloc.dart | 132 ++++++++ .../filter/filter_create_bloc.dart | 1 - .../widgets/filter/choicechip/date.dart | 295 +++++++++++++++++- .../lib/util/int64_extension.dart | 3 + .../lib/style_widget/text_field.dart | 3 + 9 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/date_filter_editor_bloc.dart diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart index 8acc80a0dd..df36eb864c 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart @@ -1,6 +1,6 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -138,5 +138,27 @@ void main() { await tester.pumpAndSettle(); }); + + testWidgets('add date filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.DateTime, 'date'); + + // By default, the condition of date filter is current day and time + tester.assertNumberOfRowsInGridPage(0); + + await tester.tapFilterButtonInGrid('date'); + await tester.tapDateFilterButtonInGrid(); + await tester.tapDateFilterCondition(DateFilterConditionPB.DateBefore); + tester.assertNumberOfRowsInGridPage(7); + + await tester.tapDateFilterButtonInGrid(); + await tester.tapDateFilterCondition(DateFilterConditionPB.DateIsEmpty); + tester.assertNumberOfRowsInGridPage(3); + + await tester.pumpAndSettle(); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index c76c41f62a..6e2c95db71 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -24,6 +24,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choic import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart'; @@ -1096,6 +1097,10 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(find.byType(ChecklistFilterConditionList)); } + Future tapDateFilterButtonInGrid() async { + await tapButton(find.byType(DateFilterConditionPBList)); + } + /// The [SelectOptionFilterList] must show up first. Future tapOptionFilterWithName(String name) async { final findCell = find.descendant( @@ -1129,6 +1134,15 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(button); } + Future tapDateFilterCondition(DateFilterConditionPB condition) async { + final button = find.descendant( + of: find.byType(HoverButton), + matching: find.text(condition.filterName), + ); + + await tapButton(button); + } + /// Should call [tapDatabaseSettingButton] first. Future tapViewPropertiesButton() async { final findSettingItem = find.byType(DatabaseSettingsList); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart index b8b98e77bb..01930dc31b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -65,6 +65,7 @@ class FieldInfo with _$FieldInfo { case FieldType.Checklist: case FieldType.URL: case FieldType.Time: + case FieldType.DateTime: return true; default: return false; diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart index 7434c5e497..d86363e75e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -94,18 +94,11 @@ class FilterBackendService { required String fieldId, String? filterId, required DateFilterConditionPB condition, - required FieldType fieldType, int? start, int? end, int? timestamp, }) { - assert( - fieldType == FieldType.DateTime || - fieldType == FieldType.LastEditedTime || - fieldType == FieldType.CreatedTime, - ); - - final filter = DateFilterPB(); + final filter = DateFilterPB()..condition = condition; if (timestamp != null) { filter.timestamp = $fixnum.Int64(timestamp); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/date_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/date_filter_editor_bloc.dart new file mode 100644 index 0000000000..476e0b2bfb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/date_filter_editor_bloc.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'date_filter_editor_bloc.freezed.dart'; + +class DateFilterEditorBloc + extends Bloc { + DateFilterEditorBloc({required this.filterInfo}) + : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), + _listener = FilterListener( + viewId: filterInfo.viewId, + filterId: filterInfo.filter.id, + ), + super(DateFilterEditorState.initial(filterInfo)) { + _dispatch(); + _startListening(); + } + + final FilterInfo filterInfo; + final FilterBackendService _filterBackendSvc; + final FilterListener _listener; + + void _dispatch() { + on( + (event, emit) async { + event.when( + didReceiveFilter: (filter) { + final filterInfo = state.filterInfo.copyWith(filter: filter); + emit( + state.copyWith( + filterInfo: filterInfo, + filter: filterInfo.dateFilter()!, + ), + ); + }, + updateCondition: (DateFilterConditionPB condition) { + _filterBackendSvc.insertDateFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: condition, + start: state.filter.start.toInt(), + end: state.filter.end.toInt(), + timestamp: state.filter.timestamp.toInt(), + ); + }, + updateDate: (date) { + _filterBackendSvc.insertDateFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + timestamp: date.millisecondsSinceEpoch ~/ 1000, + ); + }, + updateRange: (start, end) { + assert(start != null || end != null); + _filterBackendSvc.insertDateFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + start: start != null + ? start.millisecondsSinceEpoch ~/ 1000 + : state.filter.start.toInt(), + end: end != null + ? end.millisecondsSinceEpoch ~/ 1000 + : state.filter.end.toInt(), + ); + }, + delete: () { + _filterBackendSvc.deleteFilter( + fieldId: filterInfo.fieldInfo.id, + filterId: filterInfo.filter.id, + ); + }, + ); + }, + ); + } + + void _startListening() { + _listener.start( + onUpdated: (filter) { + if (!isClosed) { + add(DateFilterEditorEvent.didReceiveFilter(filter)); + } + }, + ); + } + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } +} + +@freezed +class DateFilterEditorEvent with _$DateFilterEditorEvent { + const factory DateFilterEditorEvent.didReceiveFilter(FilterPB filter) = + _DidReceiveFilter; + const factory DateFilterEditorEvent.updateCondition( + DateFilterConditionPB condition, + ) = _UpdateCondition; + const factory DateFilterEditorEvent.updateDate( + DateTime date, + ) = _UpdateDate; + const factory DateFilterEditorEvent.updateRange({ + DateTime? start, + DateTime? end, + }) = _UpdateRange; + const factory DateFilterEditorEvent.delete() = _Delete; +} + +@freezed +class DateFilterEditorState with _$DateFilterEditorState { + const factory DateFilterEditorState({ + required FilterInfo filterInfo, + required DateFilterPB filter, + }) = _DateFilterEditorState; + + factory DateFilterEditorState.initial(FilterInfo filterInfo) { + return DateFilterEditorState( + filterInfo: filterInfo, + filter: filterInfo.dateFilter()!, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index b4a8dc72b7..c1777bdbd3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart @@ -103,7 +103,6 @@ class GridCreateFilterBloc fieldId: fieldId, condition: DateFilterConditionPB.DateIs, timestamp: timestamp, - fieldType: field.fieldType, ); case FieldType.MultiSelect: return _filterBackendSvc.insertSelectOptionFilter( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart index 3c97aaddb2..6865da3fa8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart @@ -1,15 +1,304 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/date_filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:appflowy/util/int64_extension.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; import '../filter_info.dart'; + import 'choicechip.dart'; -class DateFilterChoicechip extends StatelessWidget { - const DateFilterChoicechip({required this.filterInfo, super.key}); +class DateFilterChoicechip extends StatefulWidget { + const DateFilterChoicechip({ + super.key, + required this.filterInfo, + }); final FilterInfo filterInfo; + @override + State createState() => _DateFilterChoicechipState(); +} + +class _DateFilterChoicechipState extends State { @override Widget build(BuildContext context) { - return ChoiceChipButton(filterInfo: filterInfo); + return BlocProvider( + create: (_) => DateFilterEditorBloc(filterInfo: widget.filterInfo), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 120)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const DateFilterEditor(), + ); + }, + child: ChoiceChipButton(filterInfo: state.filterInfo), + ); + }, + ), + ); + } +} + +class DateFilterEditor extends StatefulWidget { + const DateFilterEditor({super.key}); + + @override + State createState() => _DateFilterEditorState(); +} + +class _DateFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + final _popover = PopoverController(); + final _textEditingController = TextEditingController(); + + @override + void dispose() { + popoverMutex.dispose(); + _textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List children = [ + _buildFilterPanel(context), + if (state.filter.condition != DateFilterConditionPB.DateIsEmpty && + state.filter.condition != + DateFilterConditionPB.DateIsNotEmpty) ...[ + const VSpace(4), + _buildFilterDateField(context), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel(BuildContext context) { + final state = context.watch().state; + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + state.filterInfo.fieldInfo.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: DateFilterConditionPBList( + filterInfo: state.filterInfo, + popoverMutex: popoverMutex, + onCondition: (condition) { + context + .read() + .add(DateFilterEditorEvent.updateCondition(condition)); + _popover.close(); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(const DateFilterEditorEvent.delete()); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterDateField(BuildContext context) { + final filter = context.watch().state.filter; + + final isRange = filter.condition == DateFilterConditionPB.DateWithIn; + String? text; + + if (isRange) { + text = + "${filter.start.dateTime.defaultFormat ?? ""} - ${filter.end.dateTime.defaultFormat ?? ""}"; + text = text == " - " ? null : text; + } else { + text = filter.timestamp.dateTime.defaultFormat; + } + _textEditingController.text = text ?? ""; + + return AppFlowyPopover( + controller: _popover, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(260, 620)), + margin: EdgeInsets.zero, + child: FlowyTextField( + controller: _textEditingController, + readOnly: true, + onTap: _popover.show, + autoFocus: false, + hintText: LocaleKeys.grid_field_dateTime.tr(), + ), + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + final filter = state.filter; + final isRange = + filter.condition == DateFilterConditionPB.DateWithIn; + + return AppFlowyDatePicker( + isRange: isRange, + timeHintText: LocaleKeys.grid_field_selectTime.tr(), + includeTime: false, + dateFormat: DateFormatPB.Friendly, + timeFormat: TimeFormatPB.TwentyFourHour, + selectedDay: filter.timestamp.dateTime, + startDay: isRange ? filter.start.dateTime : null, + endDay: isRange ? filter.end.dateTime : null, + onDaySelected: (selectedDay, _) { + Function(DateTime) event = + (date) => DateFilterEditorEvent.updateDate(date); + if (isRange) { + event = (date) => + DateFilterEditorEvent.updateRange(start: date); + } + + context.read().add(event(selectedDay)); + if (isRange) { + _popover.close(); + } + }, + onRangeSelected: (start, end, _) => + context.read().add( + DateFilterEditorEvent.updateRange( + start: start, + end: end, + ), + ), + onIncludeTimeChanged: (_) => {}, + ); + }, + ), + ); + }, + ); + } +} + +class DateFilterConditionPBList extends StatelessWidget { + const DateFilterConditionPBList({ + super.key, + required this.filterInfo, + required this.popoverMutex, + required this.onCondition, + }); + + final FilterInfo filterInfo; + final PopoverMutex popoverMutex; + final Function(DateFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + final dateFilter = filterInfo.dateFilter()!; + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: DateFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + dateFilter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: dateFilter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final DateFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension DateFilterConditionPBExtension on DateFilterConditionPB { + String get filterName { + return switch (this) { + DateFilterConditionPB.DateIs => LocaleKeys.grid_dateFilter_is.tr(), + DateFilterConditionPB.DateBefore => + LocaleKeys.grid_dateFilter_before.tr(), + DateFilterConditionPB.DateAfter => LocaleKeys.grid_dateFilter_after.tr(), + DateFilterConditionPB.DateOnOrBefore => + LocaleKeys.grid_dateFilter_onOrBefore.tr(), + DateFilterConditionPB.DateOnOrAfter => + LocaleKeys.grid_dateFilter_onOrAfter.tr(), + DateFilterConditionPB.DateWithIn => + LocaleKeys.grid_dateFilter_between.tr(), + DateFilterConditionPB.DateIsEmpty => + LocaleKeys.grid_dateFilter_empty.tr(), + DateFilterConditionPB.DateIsNotEmpty => + LocaleKeys.grid_dateFilter_notEmpty.tr(), + _ => "", + }; + } +} + +extension DateTimeChoicechipExtension on DateTime { + DateTime get considerLocal { + return DateTime(year, month, day); + } +} + +extension DateTimeDefaultFormatExtension on DateTime? { + String? get defaultFormat { + return this != null ? DateFormat('dd/MM/yyyy').format(this!) : null; } } diff --git a/frontend/appflowy_flutter/lib/util/int64_extension.dart b/frontend/appflowy_flutter/lib/util/int64_extension.dart index 8c7f1579f5..ddaabd5bbd 100644 --- a/frontend/appflowy_flutter/lib/util/int64_extension.dart +++ b/frontend/appflowy_flutter/lib/util/int64_extension.dart @@ -2,4 +2,7 @@ import 'package:fixnum/fixnum.dart'; extension DateConversion on Int64 { DateTime toDateTime() => DateTime.fromMillisecondsSinceEpoch(toInt() * 1000); + + DateTime? get dateTime => + toInt() != 0 ? DateTime.fromMillisecondsSinceEpoch(toInt() * 1000) : null; } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart index aa408f73e1..c4f72f2261 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -39,6 +39,7 @@ class FlowyTextField extends StatefulWidget { final bool readOnly; final Color? enableBorderColor; final BorderRadius? borderRadius; + final void Function()? onTap; final Function(PointerDownEvent)? onTapOutside; const FlowyTextField({ @@ -77,6 +78,7 @@ class FlowyTextField extends StatefulWidget { this.readOnly = false, this.enableBorderColor, this.borderRadius, + this.onTap, this.onTapOutside, }); @@ -163,6 +165,7 @@ class FlowyTextFieldState extends State { }, onSubmitted: _onSubmitted, onEditingComplete: widget.onEditingComplete, + onTap: widget.onTap, onTapOutside: widget.onTapOutside, minLines: 1, maxLines: widget.maxLines,