From c0796e8ae554411519e8b4e2fa72b8cca589eba4 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Fri, 23 Feb 2024 20:47:19 +0800 Subject: [PATCH] feat: mobile sort editor (#4714) --- .../presentation/base/app_bar_actions.dart | 51 +- .../view/database_sort_bottom_sheet.dart | 555 ++++++++++++++++++ .../database_sort_bottom_sheet_cubit.dart | 77 +++ .../widgets/flowy_option_tile.dart | 2 +- .../application/sort/sort_service.dart | 12 +- .../application/sort/sort_create_bloc.dart | 1 - .../application/sort/sort_editor_bloc.dart | 72 ++- .../widgets/sort/sort_editor.dart | 10 +- .../setting/mobile_database_controls.dart | 121 +++- .../16x/icon_right-small-ccm_outlined.svg | 3 + frontend/resources/translations/en.json | 5 +- 11 files changed, 835 insertions(+), 74 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart create mode 100644 frontend/resources/flowy_icons/16x/icon_right-small-ccm_outlined.svg diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart index ebca534dab..4f611a3435 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart @@ -8,14 +8,17 @@ class AppBarBackButton extends StatelessWidget { const AppBarBackButton({ super.key, this.onTap, + this.padding, }); final VoidCallback? onTap; + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { return AppBarButton( onTap: onTap ?? () => Navigator.pop(context), + padding: padding, child: const FlowySvg( FlowySvgs.m_app_bar_back_s, ), @@ -73,8 +76,8 @@ class AppBarDoneButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - isActionButton: true, onTap: onTap, + padding: const EdgeInsets.fromLTRB(12, 12, 8, 12), child: FlowyText( LocaleKeys.button_Done.tr(), color: Theme.of(context).colorScheme.primary, @@ -85,6 +88,39 @@ class AppBarDoneButton extends StatelessWidget { } } +class AppBarSaveButton extends StatelessWidget { + const AppBarSaveButton({ + super.key, + required this.onTap, + this.enable = true, + this.padding = const EdgeInsets.fromLTRB(12, 12, 8, 12), + }); + + final VoidCallback onTap; + final bool enable; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: () { + if (enable) { + onTap(); + } + }, + padding: padding, + child: FlowyText( + LocaleKeys.button_save.tr(), + color: enable + ? Theme.of(context).colorScheme.primary + : Theme.of(context).disabledColor, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ); + } +} + class AppBarFilledDoneButton extends StatelessWidget { const AppBarFilledDoneButton({super.key, required this.onTap}); @@ -129,7 +165,7 @@ class AppBarMoreButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - isActionButton: true, + padding: const EdgeInsets.fromLTRB(12, 12, 8, 12), onTap: () => onTap(context), child: const FlowySvg(FlowySvgs.three_dots_s), ); @@ -139,14 +175,14 @@ class AppBarMoreButton extends StatelessWidget { class AppBarButton extends StatelessWidget { const AppBarButton({ super.key, - this.isActionButton = false, required this.onTap, required this.child, + this.padding, }); final VoidCallback onTap; final Widget child; - final bool isActionButton; + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { @@ -154,12 +190,7 @@ class AppBarButton extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: onTap, child: Padding( - padding: EdgeInsets.only( - top: 12.0, - bottom: 12.0, - left: 12.0, - right: isActionButton ? 12.0 : 8.0, - ), + padding: padding ?? const EdgeInsets.all(12), child: child, ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart new file mode 100644 index 0000000000..d1e58838c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart @@ -0,0 +1,555 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +import 'database_sort_bottom_sheet_cubit.dart'; + +class MobileSortEditor extends StatefulWidget { + const MobileSortEditor({ + super.key, + }); + + @override + State createState() => _MobileSortEditorState(); +} + +class _MobileSortEditorState extends State { + final PageController _pageController = PageController(); + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MobileSortEditorCubit( + pageController: _pageController, + ), + child: Column( + children: [ + const _Header(), + SizedBox( + height: 400, //314, + child: PageView.builder( + controller: _pageController, + itemCount: 2, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return index == 0 + ? Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: const _Overview(), + ) + : const _SortDetail(); + }, + ), + ), + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + if (state.showBackButton) + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: () => context + .read() + .returnToOverview(), + ), + ), + Align( + child: FlowyText.medium( + LocaleKeys.grid_settings_sort.tr(), + fontSize: 16.0, + ), + ), + if (state.isCreatingNewSort) + Align( + alignment: Alignment.centerRight, + child: AppBarSaveButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + enable: state.newSortFieldId != null, + onTap: () { + _tryCreateSort(context, state); + context.read().returnToOverview(); + }, + ), + ), + ], + ), + ); + }, + ); + } + + void _tryCreateSort(BuildContext context, MobileSortEditorState state) { + if (state.newSortFieldId != null && state.newSortCondition != null) { + context.read().add( + SortEditorEvent.createSort( + state.newSortFieldId!, + state.newSortCondition!, + ), + ); + } + } +} + +class _Overview extends StatelessWidget { + const _Overview(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + Expanded( + child: state.sortInfos.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.sort_descending_s, + size: const Size.square(60), + color: Theme.of(context).hintColor, + ), + FlowyText( + LocaleKeys.grid_sort_empty.tr(), + color: Theme.of(context).hintColor, + ), + ], + ), + ) + : ReorderableListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: child, + ), + onReorder: (oldIndex, newIndex) => context + .read() + .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), + itemCount: state.sortInfos.length, + itemBuilder: (context, index) => _SortItem( + key: ValueKey("sort_item_$index"), + sort: state.sortInfos[index], + ), + ), + ), + Container( + height: 44, + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + ), + child: InkWell( + onTap: () { + final firstField = context + .read() + .state + .creatableFields + .firstOrNull; + if (firstField == null) { + Fluttertoast.showToast( + msg: LocaleKeys.grid_sort_cannotFindCreatableField.tr(), + gravity: ToastGravity.BOTTOM, + ); + } else { + context.read().startCreatingSort(); + } + }, + borderRadius: Corners.s10Border, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size.square(16), + ), + const HSpace(6.0), + FlowyText( + LocaleKeys.grid_sort_addSort.tr(), + fontSize: 15, + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _SortItem extends StatelessWidget { + const _SortItem({super.key, required this.sort}); + + final SortInfo sort; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric( + vertical: 4.0, + ), + decoration: BoxDecoration( + color: Theme.of(context).hoverColor, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context + .read() + .startEditingSort(sort.sortId), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Expanded( + child: FlowyText.medium( + LocaleKeys.grid_sort_by.tr(), + fontSize: 15, + ), + ), + ), + const VSpace(10), + Row( + children: [ + Expanded( + child: Container( + height: 44, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Center( + child: Row( + children: [ + Expanded( + child: FlowyText( + sort.fieldInfo.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(6.0), + FlowySvg( + FlowySvgs.icon_right_small_ccm_outlined_s, + size: const Size.square(14), + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ), + const HSpace(6), + Expanded( + child: Container( + height: 44, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsetsDirectional.only( + start: 12, + end: 10, + ), + child: Center( + child: Row( + children: [ + Expanded( + child: FlowyText( + sort.sortPB.condition.name, + ), + ), + const HSpace(6.0), + FlowySvg( + FlowySvgs.icon_right_small_ccm_outlined_s, + size: const Size.square(14), + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + Positioned( + right: 8, + top: 9, + child: InkWell( + onTap: () => context + .read() + .add(SortEditorEvent.deleteSort(sort)), + // steal from the container LongClickReorderWidget thing + onLongPress: () {}, + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 34, + child: Center( + child: FlowySvg( + FlowySvgs.trash_m, + size: const Size.square(18), + color: Theme.of(context).hintColor, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _SortDetail extends StatelessWidget { + const _SortDetail(); + + @override + Widget build(BuildContext context) { + final isCreatingNewSort = + context.read().state.isCreatingNewSort; + + return isCreatingNewSort + ? const _SortDetailContent() + : BlocSelector( + selector: (state) => state.sortInfos.firstWhere( + (sortInfo) => + sortInfo.sortId == + context.read().state.editingSortId, + ), + builder: (context, sortInfo) { + return _SortDetailContent(sortInfo: sortInfo); + }, + ); + } +} + +class _SortDetailContent extends StatelessWidget { + const _SortDetailContent({ + this.sortInfo, + }); + + final SortInfo? sortInfo; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DefaultTabController( + length: 2, + initialIndex: sortInfo == null + ? 0 + : sortInfo!.sortPB.condition == SortConditionPB.Ascending + ? 0 + : 1, + child: Container( + padding: const EdgeInsets.all(3.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).hoverColor, + ), + child: TabBar( + indicatorSize: TabBarIndicatorSize.label, + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + indicatorWeight: 0, + indicator: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surface, + ), + splashFactory: NoSplash.splashFactory, + overlayColor: const MaterialStatePropertyAll( + Colors.transparent, + ), + onTap: (index) { + final newCondition = index == 0 + ? SortConditionPB.Ascending + : SortConditionPB.Descending; + _changeCondition(context, newCondition); + }, + tabs: [ + Tab( + height: 34, + child: Center( + child: FlowyText( + LocaleKeys.grid_sort_ascending.tr(), + fontSize: 14, + ), + ), + ), + Tab( + height: 34, + child: Center( + child: FlowyText( + LocaleKeys.grid_sort_descending.tr(), + fontSize: 14, + ), + ), + ), + ], + ), + ), + ), + ), + const VSpace(20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyText( + LocaleKeys.grid_settings_sortBy.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(4.0), + const Divider( + height: 0.5, + thickness: 0.5, + ), + Expanded( + child: BlocBuilder( + builder: (context, state) { + final fields = state.allFields + .where( + (field) => + field.canCreateSort || + sortInfo != null && sortInfo!.fieldId == field.id, + ) + .toList(); + return ListView.builder( + itemCount: fields.length, + itemBuilder: (context, index) { + final fieldInfo = fields[index]; + final isSelected = sortInfo == null + ? context + .watch() + .state + .newSortFieldId == + fieldInfo.id + : sortInfo!.fieldId == fieldInfo.id; + return FlowyOptionTile.checkbox( + text: fieldInfo.field.name, + isSelected: isSelected, + showTopBorder: false, + onTap: () { + if (!isSelected) { + _changeFieldId(context, fieldInfo.id); + } + }, + ); + }, + ); + }, + ), + ), + ], + ); + } + + void _changeCondition(BuildContext context, SortConditionPB newCondition) { + if (sortInfo == null) { + context.read().changeSortCondition(newCondition); + } else { + context.read().add( + SortEditorEvent.editSort( + sortInfo!.sortId, + null, + newCondition, + ), + ); + } + } + + void _changeFieldId(BuildContext context, String newFieldId) { + if (sortInfo == null) { + context.read().changeFieldId(newFieldId); + } else { + context.read().add( + SortEditorEvent.editSort( + sortInfo!.sortId, + newFieldId, + null, + ), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart new file mode 100644 index 0000000000..684f98e564 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'database_sort_bottom_sheet_cubit.freezed.dart'; + +class MobileSortEditorCubit extends Cubit { + MobileSortEditorCubit({ + required this.pageController, + }) : super(MobileSortEditorState.initial()); + + final PageController pageController; + + void returnToOverview() { + _animateToPage(0); + emit(MobileSortEditorState.initial()); + } + + void startCreatingSort() { + _animateToPage(1); + emit( + state.copyWith( + showBackButton: true, + isCreatingNewSort: true, + newSortCondition: SortConditionPB.Ascending, + ), + ); + } + + void startEditingSort(String sortId) { + _animateToPage(1); + emit( + state.copyWith( + showBackButton: true, + editingSortId: sortId, + ), + ); + } + + /// only used when creating a new sort + void changeFieldId(String fieldId) { + emit(state.copyWith(newSortFieldId: fieldId)); + } + + /// only used when creating a new sort + void changeSortCondition(SortConditionPB condition) { + emit(state.copyWith(newSortCondition: condition)); + } + + Future _animateToPage(int page) async { + return pageController.animateToPage( + page, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } +} + +@freezed +class MobileSortEditorState with _$MobileSortEditorState { + factory MobileSortEditorState({ + required bool showBackButton, + required String? editingSortId, + required bool isCreatingNewSort, + required String? newSortFieldId, + required SortConditionPB? newSortCondition, + }) = _MobileSortEditorState; + + factory MobileSortEditorState.initial() => MobileSortEditorState( + showBackButton: false, + editingSortId: null, + isCreatingNewSort: false, + newSortFieldId: null, + newSortCondition: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart index f9b5104a10..5ec4ce84d2 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart @@ -218,7 +218,7 @@ class FlowyOptionTile extends StatelessWidget { final padding = EdgeInsets.symmetric( horizontal: leading == null ? 0.0 : 12.0, - vertical: 16.0, + vertical: 14.0, ); return Expanded( diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart index 2cfcd2d843..72634e67d2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart @@ -3,7 +3,6 @@ import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; @@ -24,16 +23,15 @@ class SortBackendService { } Future> updateSort({ - required String fieldId, required String sortId, - required FieldType fieldType, + required String fieldId, required SortConditionPB condition, }) { final insertSortPayload = UpdateSortPayloadPB.create() - ..fieldId = fieldId ..viewId = viewId - ..condition = condition - ..sortId = sortId; + ..sortId = sortId + ..fieldId = fieldId + ..condition = condition; final payload = DatabaseSettingChangesetPB.create() ..viewId = viewId @@ -51,7 +49,6 @@ class SortBackendService { Future> insertSort({ required String fieldId, - required FieldType fieldType, required SortConditionPB condition, }) { final insertSortPayload = UpdateSortPayloadPB.create() @@ -90,7 +87,6 @@ class SortBackendService { Future> deleteSort({ required String fieldId, required String sortId, - required FieldType fieldType, }) { final deleteSortPayload = DeleteSortPayloadPB.create() ..sortId = sortId diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart index 28435dfa08..cdeb37bd88 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart @@ -91,7 +91,6 @@ class CreateSortBloc extends Bloc { Future> _createDefaultSort(FieldInfo field) async { final result = await _sortBackendSvc.insertSort( fieldId: field.id, - fieldType: field.fieldType, condition: SortConditionPB.Ascending, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart index 0eb2b9c6c8..4cb258f7b5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_in import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbserver.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -20,14 +21,21 @@ class SortEditorBloc extends Bloc { required this.fieldController, required List sortInfos, }) : _sortBackendSvc = SortBackendService(viewId: viewId), - super(SortEditorState.initial(sortInfos, fieldController.fieldInfos)) { + super( + SortEditorState.initial( + sortInfos, + fieldController.fieldInfos, + ), + ) { _dispatch(); } final String viewId; final SortBackendService _sortBackendSvc; final FieldController fieldController; + void Function(List)? _onFieldFn; + void Function(List)? _onSortsFn; void _dispatch() { on( @@ -37,25 +45,38 @@ class SortEditorBloc extends Bloc { _startListening(); }, didReceiveFields: (List fields) { - final List allFields = List.from(fields); - final List creatableFields = List.from(fields); - creatableFields.retainWhere((field) => field.canCreateSort); emit( state.copyWith( - allFields: allFields, - creatableFields: creatableFields, + allFields: fields, + creatableFields: getCreatableSorts(fields), ), ); }, - setCondition: (SortInfo sortInfo, SortConditionPB condition) async { - final result = await _sortBackendSvc.updateSort( - fieldId: sortInfo.fieldInfo.id, - sortId: sortInfo.sortId, - fieldType: sortInfo.fieldInfo.fieldType, + createSort: (String fieldId, SortConditionPB condition) async { + final result = await _sortBackendSvc.insertSort( + fieldId: fieldId, condition: condition, ); result.fold((l) => {}, (err) => Log.error(err)); }, + editSort: ( + String sortId, + String? fieldId, + SortConditionPB? condition, + ) async { + final sortInfo = state.sortInfos + .firstWhereOrNull((element) => element.sortId == sortId); + if (sortInfo == null) { + return; + } + + final result = await _sortBackendSvc.updateSort( + sortId: sortId, + fieldId: fieldId ?? sortInfo.fieldId, + condition: condition ?? sortInfo.sortPB.condition, + ); + result.fold((l) => {}, (err) => Log.error(err)); + }, deleteAllSorts: () async { final result = await _sortBackendSvc.deleteAllSorts(); result.fold((l) => {}, (err) => Log.error(err)); @@ -67,7 +88,6 @@ class SortEditorBloc extends Bloc { final result = await _sortBackendSvc.deleteSort( fieldId: sortInfo.fieldInfo.id, sortId: sortInfo.sortId, - fieldType: sortInfo.fieldInfo.fieldType, ); result.fold((l) => null, (err) => Log.error(err)); }, @@ -97,22 +117,25 @@ class SortEditorBloc extends Bloc { _onFieldFn = (fields) { add(SortEditorEvent.didReceiveFields(List.from(fields))); }; + _onSortsFn = (sorts) { + add(SortEditorEvent.didReceiveSorts(sorts)); + }; fieldController.addListener( listenWhen: () => !isClosed, onReceiveFields: _onFieldFn, - onSorts: (sorts) { - add(SortEditorEvent.didReceiveSorts(sorts)); - }, + onSorts: _onSortsFn, ); } @override Future close() async { - if (_onFieldFn != null) { - fieldController.removeListener(onFieldsListener: _onFieldFn); - _onFieldFn = null; - } + fieldController.removeListener( + onFieldsListener: _onFieldFn, + onSortsListener: _onSortsFn, + ); + _onFieldFn = null; + _onSortsFn = null; return super.close(); } } @@ -124,10 +147,15 @@ class SortEditorEvent with _$SortEditorEvent { _DidReceiveFields; const factory SortEditorEvent.didReceiveSorts(List sortInfos) = _DidReceiveSorts; - const factory SortEditorEvent.setCondition( - SortInfo sortInfo, + const factory SortEditorEvent.createSort( + String fieldId, SortConditionPB condition, - ) = _SetCondition; + ) = _CreateSort; + const factory SortEditorEvent.editSort( + String sortId, + String? fieldId, + SortConditionPB? condition, + ) = _EditSort; const factory SortEditorEvent.deleteSort(SortInfo sortInfo) = _DeleteSort; const factory SortEditorEvent.deleteAllSorts() = _DeleteAllSorts; const factory SortEditorEvent.reorderSort(int oldIndex, int newIndex) = diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart index 9dfaa03d3f..2bd844a958 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart @@ -299,9 +299,13 @@ class _DatabaseSortItemOrderButtonState popupBuilder: (BuildContext popoverContext) { return OrderPanel( onCondition: (condition) { - context - .read() - .add(SortEditorEvent.setCondition(widget.sortInfo, condition)); + context.read().add( + SortEditorEvent.editSort( + widget.sortInfo.sortId, + null, + condition, + ), + ); popoverController.close(); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart index 278071d187..ee3aea478e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart @@ -2,12 +2,15 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/view/database_field_list.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_sort_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_menu_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -56,24 +59,25 @@ class MobileDatabaseControls extends StatelessWidget { return const SizedBox.shrink(); } - return _DatabaseControlButton( - icon: FlowySvgs.m_field_hide_s, - onTap: () => showTransitionMobileBottomSheet( - context, - showHeader: true, - showBackButton: true, - title: LocaleKeys.grid_settings_properties.tr(), - showDivider: true, - builder: (_) { - return BlocProvider.value( - value: context.read(), - child: MobileDatabaseFieldList( - databaseController: controller, - canCreate: false, - ), - ); - }, - ), + return SeparatedRow( + separatorBuilder: () => const HSpace(8.0), + children: [ + _DatabaseControlButton( + icon: FlowySvgs.sort_ascending_s, + count: context.watch().state.sortInfos.length, + onTap: () => _showEditSortPanelFromToolbar( + context, + controller, + ), + ), + _DatabaseControlButton( + icon: FlowySvgs.m_field_hide_s, + onTap: () => _showDatabaseFieldListFromToolbar( + context, + controller, + ), + ), + ], ); }, ), @@ -86,24 +90,85 @@ class _DatabaseControlButton extends StatelessWidget { const _DatabaseControlButton({ required this.onTap, required this.icon, + this.count = 0, }); final VoidCallback onTap; final FlowySvgData icon; + final int count; @override Widget build(BuildContext context) { - return SizedBox.square( - dimension: 36, - child: IconButton( - splashRadius: 18, - padding: EdgeInsets.zero, - onPressed: onTap, - icon: FlowySvg( - icon, - size: const Size.square(20), - ), + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: count == 0 + ? FlowySvg( + icon, + size: const Size.square(20), + ) + : Row( + children: [ + FlowySvg( + icon, + size: const Size.square(20), + color: Theme.of(context).colorScheme.primary, + ), + const HSpace(2.0), + FlowyText.medium( + count.toString(), + color: Theme.of(context).colorScheme.primary, + ), + ], + ), ), ); } } + +void _showDatabaseFieldListFromToolbar( + BuildContext context, + DatabaseController databaseController, +) { + showTransitionMobileBottomSheet( + context, + showHeader: true, + showBackButton: true, + title: LocaleKeys.grid_settings_properties.tr(), + showDivider: true, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: MobileDatabaseFieldList( + databaseController: databaseController, + canCreate: false, + ), + ); + }, + ); +} + +void _showEditSortPanelFromToolbar( + BuildContext context, + DatabaseController databaseController, +) { + showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.surface, + showDragHandle: true, + showDivider: false, + useSafeArea: false, + builder: (_) { + return BlocProvider( + create: (_) => SortEditorBloc( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + sortInfos: databaseController.fieldController.sortInfos, + )..add(const SortEditorEvent.initial()), + child: const MobileSortEditor(), + ); + }, + ); +} diff --git a/frontend/resources/flowy_icons/16x/icon_right-small-ccm_outlined.svg b/frontend/resources/flowy_icons/16x/icon_right-small-ccm_outlined.svg new file mode 100644 index 0000000000..550dc32cca --- /dev/null +++ b/frontend/resources/flowy_icons/16x/icon_right-small-ccm_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2f6c90c962..c0304e020f 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -621,8 +621,11 @@ "sort": { "ascending": "Ascending", "descending": "Descending", + "by": "By", + "empty": "No active sorts", + "cannotFindCreatableField": "Cannot find a suitable field to sort by", "deleteAllSorts": "Delete all sorts", - "addSort": "Add sort" + "addSort": "Add new sort" }, "row": { "duplicate": "Duplicate",