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
This commit is contained in:
Mohammad Zolfaghari 2024-09-24 17:33:07 +03:30 committed by GitHub
parent da7c993fd6
commit b7c598ea56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 469 additions and 13 deletions

View File

@ -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();
});
});
}

View File

@ -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<void> tapDateFilterButtonInGrid() async {
await tapButton(find.byType(DateFilterConditionPBList));
}
/// The [SelectOptionFilterList] must show up first.
Future<void> tapOptionFilterWithName(String name) async {
final findCell = find.descendant(
@ -1129,6 +1134,15 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(button);
}
Future<void> 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<void> tapViewPropertiesButton() async {
final findSettingItem = find.byType(DatabaseSettingsList);

View File

@ -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;

View File

@ -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);

View File

@ -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<DateFilterEditorEvent, DateFilterEditorState> {
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<DateFilterEditorEvent>(
(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<void> 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()!,
);
}
}

View File

@ -103,7 +103,6 @@ class GridCreateFilterBloc
fieldId: fieldId,
condition: DateFilterConditionPB.DateIs,
timestamp: timestamp,
fieldType: field.fieldType,
);
case FieldType.MultiSelect:
return _filterBackendSvc.insertSelectOptionFilter(

View File

@ -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<DateFilterChoicechip> createState() => _DateFilterChoicechipState();
}
class _DateFilterChoicechipState extends State<DateFilterChoicechip> {
@override
Widget build(BuildContext context) {
return ChoiceChipButton(filterInfo: filterInfo);
return BlocProvider(
create: (_) => DateFilterEditorBloc(filterInfo: widget.filterInfo),
child: BlocBuilder<DateFilterEditorBloc, DateFilterEditorState>(
builder: (context, state) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(200, 120)),
direction: PopoverDirection.bottomWithCenterAligned,
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<DateFilterEditorBloc>(),
child: const DateFilterEditor(),
);
},
child: ChoiceChipButton(filterInfo: state.filterInfo),
);
},
),
);
}
}
class DateFilterEditor extends StatefulWidget {
const DateFilterEditor({super.key});
@override
State<DateFilterEditor> createState() => _DateFilterEditorState();
}
class _DateFilterEditorState extends State<DateFilterEditor> {
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<DateFilterEditorBloc, DateFilterEditorState>(
builder: (context, state) {
final List<Widget> 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<DateFilterEditorBloc>().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<DateFilterEditorBloc>()
.add(DateFilterEditorEvent.updateCondition(condition));
_popover.close();
},
),
),
const HSpace(4),
DisclosureButton(
popoverMutex: popoverMutex,
onAction: (action) {
switch (action) {
case FilterDisclosureAction.delete:
context
.read<DateFilterEditorBloc>()
.add(const DateFilterEditorEvent.delete());
break;
}
},
),
],
),
);
}
Widget _buildFilterDateField(BuildContext context) {
final filter = context.watch<DateFilterEditorBloc>().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<DateFilterEditorBloc>(),
child: BlocBuilder<DateFilterEditorBloc, DateFilterEditorState>(
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<DateFilterEditorBloc>().add(event(selectedDay));
if (isRange) {
_popover.close();
}
},
onRangeSelected: (start, end, _) =>
context.read<DateFilterEditorBloc>().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<ConditionWrapper>(
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;
}
}

View File

@ -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;
}

View File

@ -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<FlowyTextField> {
},
onSubmitted: _onSubmitted,
onEditingComplete: widget.onEditingComplete,
onTap: widget.onTap,
onTapOutside: widget.onTapOutside,
minLines: 1,
maxLines: widget.maxLines,