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,