diff --git a/CHANGELOG.md b/CHANGELOG.md index 889c811c4c1c6..57ad262e1ccf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Release Notes -## Version 0.4.7 - 02/08/2024 +## Version 0.4.8 - 02/13/2024 ### Bug Fixes - Fixed a possible error when loading workspaces diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 1b338f95b98f6..7f05064bedc72 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.4.7" +APPFLOWY_VERSION = "0.4.8" FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart b/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart index c1c52bb23d7fa..a7f5726842b17 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart +++ b/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart @@ -9,7 +9,7 @@ import '../util/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('database filter', () { + group('grid filter:', () { testWidgets('add text filter', (tester) async { await tester.openV020database(); diff --git a/frontend/appflowy_flutter/integration_test/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/emoji_shortcut_test.dart new file mode 100644 index 0000000000000..4bc15dd2140ed --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/emoji_shortcut_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'util/keyboard.dart'; +import 'util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // May be better to move this to an existing test but unsure what it fits with + group('Keyboard shortcuts related to emojis', () { + testWidgets('cmd/ctrl+alt+e shortcut opens the emoji picker', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + final Finder editor = find.byType(AppFlowyEditor); + await tester.tap(editor); + await tester.pumpAndSettle(); + + expect(find.byType(EmojiSelectionMenu), findsNothing); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.keyE, + ], + tester: tester, + ); + + expect(find.byType(EmojiSelectionMenu), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index c744a5fa66382..51abc6de7be1c 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -23,6 +23,7 @@ import 'share_markdown_test.dart' as share_markdown_test; import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner; import 'switch_folder_test.dart' as switch_folder_test; import 'tabs_test.dart' as tabs_test; +import 'emoji_shortcut_test.dart' as emoji_shortcut_test; // import 'auth/supabase_auth_test.dart' as supabase_auth_test_runner; /// The main task runner for all integration tests in AppFlowy. @@ -69,6 +70,7 @@ Future main() async { // Others hotkeys_test.main(); + emoji_shortcut_test.main(); // Appearance integration test appearance_test_runner.main(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart index 60d1037b100ba..832c21440fde0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:appflowy/plugins/database/application/database_view_service.dart'; import 'package:appflowy/plugins/database/application/field_settings/field_settings_listener.dart'; import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; @@ -333,6 +335,32 @@ class FieldController { } } + void updateFieldInfos( + List newSortInfos, + SortChangesetNotificationPB changeset, + ) { + final changedFieldIds = HashSet.from([ + ...changeset.insertSorts.map((sort) => sort.sort.fieldId), + ...changeset.updateSorts.map((sort) => sort.fieldId), + ...changeset.deleteSorts.map((sort) => sort.fieldId), + ]); + + final newFieldInfos = [...fieldInfos]; + + for (final fieldId in changedFieldIds) { + final index = + newFieldInfos.indexWhere((fieldInfo) => fieldInfo.id == fieldId); + if (index == -1) { + continue; + } + newFieldInfos[index] = newFieldInfos[index].copyWith( + hasSort: newSortInfos.any((sort) => sort.fieldId == fieldId), + ); + } + + _fieldNotifier.fieldInfos = newFieldInfos; + } + _sortsListener.start( onSortChanged: (result) { if (_isDisposed) { @@ -346,6 +374,7 @@ class FieldController { updateSortFromChangeset(newSortInfos, changeset); _sortNotifier?.sorts = newSortInfos; + updateFieldInfos(newSortInfos, changeset); }, (err) => Log.error(err), ); 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 717776f075b53..0bcb645b4afae 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 @@ -47,9 +47,12 @@ class FieldInfo with _$FieldInfo { } bool get canCreateFilter { - if (hasFilter) return false; + if (hasFilter) { + return false; + } switch (field.fieldType) { + case FieldType.Number: case FieldType.Checkbox: case FieldType.MultiSelect: case FieldType.RichText: @@ -62,7 +65,9 @@ class FieldInfo with _$FieldInfo { } bool get canCreateSort { - if (hasSort) return false; + if (hasSort) { + return false; + } switch (field.fieldType) { case FieldType.RichText: diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart new file mode 100644 index 0000000000000..832bfa905ae91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; +import 'package:appflowy/plugins/database/application/filter/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 'number_filter_editor_bloc.freezed.dart'; + +class NumberFilterEditorBloc + extends Bloc { + NumberFilterEditorBloc({required this.filterInfo}) + : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), + _listener = FilterListener( + viewId: filterInfo.viewId, + filterId: filterInfo.filter.id, + ), + super(NumberFilterEditorState.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.numberFilter()!, + ), + ); + }, + updateCondition: (NumberFilterConditionPB condition) { + _filterBackendSvc.insertNumberFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: condition, + content: state.filter.content, + ); + }, + updateContent: (content) { + _filterBackendSvc.insertNumberFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + content: content, + ); + }, + delete: () { + _filterBackendSvc.deleteFilter( + fieldId: filterInfo.fieldInfo.id, + filterId: filterInfo.filter.id, + fieldType: filterInfo.fieldInfo.fieldType, + ); + }, + ); + }, + ); + } + + void _startListening() { + _listener.start( + onDeleted: () { + if (!isClosed) { + add(const NumberFilterEditorEvent.delete()); + } + }, + onUpdated: (filter) { + if (!isClosed) { + add(NumberFilterEditorEvent.didReceiveFilter(filter)); + } + }, + ); + } + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } +} + +@freezed +class NumberFilterEditorEvent with _$NumberFilterEditorEvent { + const factory NumberFilterEditorEvent.didReceiveFilter(FilterPB filter) = + _DidReceiveFilter; + const factory NumberFilterEditorEvent.updateCondition( + NumberFilterConditionPB condition, + ) = _UpdateCondition; + const factory NumberFilterEditorEvent.updateContent(String content) = + _UpdateContent; + const factory NumberFilterEditorEvent.delete() = _Delete; +} + +@freezed +class NumberFilterEditorState with _$NumberFilterEditorState { + const factory NumberFilterEditorState({ + required FilterInfo filterInfo, + required NumberFilterPB filter, + }) = _NumberFilterEditorState; + + factory NumberFilterEditorState.initial(FilterInfo filterInfo) { + return NumberFilterEditorState( + filterInfo: filterInfo, + filter: filterInfo.numberFilter()!, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart index f94f72b59c191..0947239273d48 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart @@ -1,15 +1,227 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/number_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: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'; +import '../condition_button.dart'; +import '../disclosure_button.dart'; import '../filter_info.dart'; import 'choicechip.dart'; -class NumberFilterChoicechip extends StatelessWidget { - const NumberFilterChoicechip({required this.filterInfo, super.key}); +class NumberFilterChoiceChip extends StatefulWidget { + const NumberFilterChoiceChip({ + super.key, + required this.filterInfo, + }); final FilterInfo filterInfo; + @override + State createState() => _NumberFilterChoiceChipState(); +} + +class _NumberFilterChoiceChipState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => NumberFilterEditorBloc( + filterInfo: widget.filterInfo, + ), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 100)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const NumberFilterEditor(), + ); + }, + child: ChoiceChipButton( + filterInfo: state.filterInfo, + ), + ); + }, + ), + ); + } +} + +class NumberFilterEditor extends StatefulWidget { + const NumberFilterEditor({super.key}); + + @override + State createState() => _NumberFilterEditorState(); +} + +class _NumberFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List children = [ + _buildFilterPanel(context, state), + if (state.filter.condition != NumberFilterConditionPB.NumberIsEmpty && + state.filter.condition != + NumberFilterConditionPB.NumberIsNotEmpty) ...[ + const VSpace(4), + _buildFilterNumberField(context, state), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel( + BuildContext context, + NumberFilterEditorState state, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + state.filterInfo.fieldInfo.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: NumberFilterConditionPBList( + filterInfo: state.filterInfo, + popoverMutex: popoverMutex, + onCondition: (condition) { + context + .read() + .add(NumberFilterEditorEvent.updateCondition(condition)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(const NumberFilterEditorEvent.delete()); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterNumberField( + BuildContext context, + NumberFilterEditorState state, + ) { + return FlowyTextField( + text: state.filter.content, + hintText: LocaleKeys.grid_settings_typeAValue.tr(), + debounceDuration: const Duration(milliseconds: 300), + autoFocus: false, + onChanged: (text) { + context + .read() + .add(NumberFilterEditorEvent.updateContent(text)); + }, + ); + } +} + +class NumberFilterConditionPBList extends StatelessWidget { + const NumberFilterConditionPBList({ + super.key, + required this.filterInfo, + required this.popoverMutex, + required this.onCondition, + }); + + final FilterInfo filterInfo; + final PopoverMutex popoverMutex; + final Function(NumberFilterConditionPB) onCondition; + @override Widget build(BuildContext context) { - return ChoiceChipButton(filterInfo: filterInfo); + final numberFilter = filterInfo.numberFilter()!; + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: NumberFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + numberFilter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: numberFilter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final NumberFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension NumberFilterConditionPBExtension on NumberFilterConditionPB { + String get filterName { + return switch (this) { + NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), + NumberFilterConditionPB.NotEqual => + LocaleKeys.grid_numberFilter_notEqual.tr(), + NumberFilterConditionPB.LessThan => + LocaleKeys.grid_numberFilter_lessThan.tr(), + NumberFilterConditionPB.LessThanOrEqualTo => + LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), + NumberFilterConditionPB.GreaterThan => + LocaleKeys.grid_numberFilter_greaterThan.tr(), + NumberFilterConditionPB.GreaterThanOrEqualTo => + LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), + NumberFilterConditionPB.NumberIsEmpty => + LocaleKeys.grid_numberFilter_isEmpty.tr(), + NumberFilterConditionPB.NumberIsNotEmpty => + LocaleKeys.grid_numberFilter_isNotEmpty.tr(), + _ => "", + }; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart index 54fa63c2c0333..5d2406094a040 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart @@ -27,51 +27,37 @@ class SelectOptionFilterList extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) { - late SelectOptionFilterListBloc bloc; - if (filterInfo.fieldInfo.fieldType == FieldType.SingleSelect) { - bloc = SelectOptionFilterListBloc( - selectedOptionIds: selectedOptionIds, - delegate: - SingleSelectOptionFilterDelegateImpl(filterInfo: filterInfo), - ); - } else { - bloc = SelectOptionFilterListBloc( - selectedOptionIds: selectedOptionIds, - delegate: - MultiSelectOptionFilterDelegateImpl(filterInfo: filterInfo), - ); - } - - bloc.add(const SelectOptionFilterListEvent.initial()); - return bloc; + return SelectOptionFilterListBloc( + selectedOptionIds: selectedOptionIds, + delegate: filterInfo.fieldInfo.fieldType == FieldType.SingleSelect + ? SingleSelectOptionFilterDelegateImpl(filterInfo: filterInfo) + : MultiSelectOptionFilterDelegateImpl(filterInfo: filterInfo), + )..add(const SelectOptionFilterListEvent.initial()); }, child: - BlocListener( + BlocConsumer( listenWhen: (previous, current) => previous.selectedOptionIds != current.selectedOptionIds, listener: (context, state) { onSelectedOptions(state.selectedOptionIds.toList()); }, - child: BlocBuilder( - builder: (context, state) { - return ListView.separated( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.visibleOptions.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemBuilder: (BuildContext context, int index) { - final option = state.visibleOptions[index]; - return SelectOptionFilterCell( - option: option.optionPB, - isSelected: option.isSelected, - ); - }, - ); - }, - ), + builder: (context, state) { + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: state.visibleOptions.length, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemBuilder: (BuildContext context, int index) { + final option = state.visibleOptions[index]; + return SelectOptionFilterCell( + option: option.optionPB, + isSelected: option.isSelected, + ); + }, + ); + }, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart index 36c6ebf030367..97fc5907482d8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart @@ -1,11 +1,5 @@ import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; class FilterInfo { FilterInfo(this.viewId, this.filter, this.fieldInfo); @@ -27,44 +21,39 @@ class FilterInfo { String get fieldId => filter.fieldId; DateFilterPB? dateFilter() { - if (![ - FieldType.DateTime, - FieldType.LastEditedTime, - FieldType.CreatedTime, - ].contains(filter.fieldType)) { - return null; - } - return DateFilterPB.fromBuffer(filter.data); + return filter.fieldType == FieldType.DateTime + ? DateFilterPB.fromBuffer(filter.data) + : null; } TextFilterPB? textFilter() { - if (filter.fieldType != FieldType.RichText) { - return null; - } - return TextFilterPB.fromBuffer(filter.data); + return filter.fieldType == FieldType.RichText + ? TextFilterPB.fromBuffer(filter.data) + : null; } CheckboxFilterPB? checkboxFilter() { - if (filter.fieldType != FieldType.Checkbox) { - return null; - } - return CheckboxFilterPB.fromBuffer(filter.data); + return filter.fieldType == FieldType.Checkbox + ? CheckboxFilterPB.fromBuffer(filter.data) + : null; } SelectOptionFilterPB? selectOptionFilter() { - if (filter.fieldType == FieldType.SingleSelect || - filter.fieldType == FieldType.MultiSelect) { - return SelectOptionFilterPB.fromBuffer(filter.data); - } else { - return null; - } + return filter.fieldType == FieldType.SingleSelect || + filter.fieldType == FieldType.MultiSelect + ? SelectOptionFilterPB.fromBuffer(filter.data) + : null; } ChecklistFilterPB? checklistFilter() { - if (filter.fieldType == FieldType.Checklist) { - return ChecklistFilterPB.fromBuffer(filter.data); - } else { - return null; - } + return filter.fieldType == FieldType.Checklist + ? ChecklistFilterPB.fromBuffer(filter.data) + : null; + } + + NumberFilterPB? numberFilter() { + return filter.fieldType == FieldType.Number + ? NumberFilterPB.fromBuffer(filter.data) + : null; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart index 524a17cee49ec..f661ea57de2e9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart @@ -17,31 +17,18 @@ class FilterMenuItem extends StatelessWidget { @override Widget build(BuildContext context) { - return buildFilterChoicechip(filterInfo); - } -} - -Widget buildFilterChoicechip(FilterInfo filterInfo) { - switch (filterInfo.fieldInfo.fieldType) { - case FieldType.Checkbox: - return CheckboxFilterChoicechip(filterInfo: filterInfo); - case FieldType.DateTime: - case FieldType.LastEditedTime: - case FieldType.CreatedTime: - return DateFilterChoicechip(filterInfo: filterInfo); - case FieldType.MultiSelect: - return SelectOptionFilterChoicechip(filterInfo: filterInfo); - case FieldType.Number: - return NumberFilterChoicechip(filterInfo: filterInfo); - case FieldType.RichText: - return TextFilterChoicechip(filterInfo: filterInfo); - case FieldType.SingleSelect: - return SelectOptionFilterChoicechip(filterInfo: filterInfo); - case FieldType.URL: - return URLFilterChoicechip(filterInfo: filterInfo); - case FieldType.Checklist: - return ChecklistFilterChoicechip(filterInfo: filterInfo); - default: - return const SizedBox(); + return switch (filterInfo.fieldInfo.fieldType) { + FieldType.Checkbox => CheckboxFilterChoicechip(filterInfo: filterInfo), + FieldType.DateTime => DateFilterChoicechip(filterInfo: filterInfo), + FieldType.MultiSelect => + SelectOptionFilterChoicechip(filterInfo: filterInfo), + FieldType.Number => NumberFilterChoiceChip(filterInfo: filterInfo), + FieldType.RichText => TextFilterChoicechip(filterInfo: filterInfo), + FieldType.SingleSelect => + SelectOptionFilterChoicechip(filterInfo: filterInfo), + FieldType.URL => URLFilterChoicechip(filterInfo: filterInfo), + FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo), + _ => const SizedBox(), + }; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index eb8ad38ef2a8a..8b959daae4efe 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -32,6 +32,7 @@ final List commandShortcutEvents = [ customCutCommand, ...customTextAlignCommands, ...standardCommandShortcutEvents, + emojiShortcutEvent, ]; final List defaultCommandShortcutEvents = [ @@ -93,6 +94,7 @@ class _AppFlowyEditorPageState extends State { customCutCommand, ...customTextAlignCommands, ...standardCommandShortcutEvents, + emojiShortcutEvent, ..._buildFindAndReplaceCommands(), ]; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart index 0ea296a7d4523..1c2dc3650f832 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart @@ -17,10 +17,12 @@ SelectionMenuItem emojiMenuItem = SelectionMenuItem( keywords: ['emoji'], handler: (editorState, menuService, context) { final container = Overlay.of(context); + menuService.dismiss(); showEmojiPickerMenu( container, editorState, - menuService, + menuService.alignment, + menuService.offset, ); }, ); @@ -28,12 +30,9 @@ SelectionMenuItem emojiMenuItem = SelectionMenuItem( void showEmojiPickerMenu( OverlayState container, EditorState editorState, - SelectionMenuService menuService, + Alignment alignment, + Offset offset, ) { - menuService.dismiss(); - - final alignment = menuService.alignment; - final offset = menuService.offset; final top = alignment == Alignment.topLeft ? offset.dy : null; final bottom = alignment == Alignment.bottomLeft ? offset.dy : null; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart index 573710742c36e..a369cc6b87be4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart @@ -1,4 +1,5 @@ export 'emoji_menu_item.dart'; +export 'emoji_shortcut_event.dart'; export 'src/emji_picker_config.dart'; export 'src/emoji_picker.dart'; export 'src/emoji_picker_builder.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart new file mode 100644 index 0000000000000..e55d0325f477c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart @@ -0,0 +1,87 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; + +final CommandShortcutEvent emojiShortcutEvent = CommandShortcutEvent( + key: 'Ctrl + Alt + E to show emoji picker', + command: 'ctrl+alt+e', + macOSCommand: 'cmd+alt+e', + getDescription: () => 'Show an emoji picker', + handler: _emojiShortcutHandler, +); + +CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + final context = editorState.getNodeAtPath(selection.start.path)?.context; + if (context == null) { + return KeyEventResult.ignored; + } + + final container = Overlay.of(context); + + Alignment alignment = Alignment.topLeft; + Offset offset = Offset.zero; + + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return KeyEventResult.ignored; + } + final rect = selectionRects.first; + + // Calculate the offset and alignment + // Don't like these values being hardcoded but unsure how to grab the + // values dynamically to match the /emoji command. + const menuHeight = 200.0; + const menuOffset = Offset(10, 10); // Tried (0, 10) but that looked off + + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + // show below default + alignment = Alignment.topLeft; + final bottomRight = rect.bottomRight; + final topRight = rect.topRight; + final newOffset = bottomRight + menuOffset; + offset = Offset( + newOffset.dx, + newOffset.dy, + ); + + // show above + if (newOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { + offset = topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + newOffset.dx, + MediaQuery.of(context).size.height - newOffset.dy, + ); + } + + // show on left + if (offset.dx - editorOffset.dx > editorWidth / 2) { + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorWidth - offset.dx + editorOffset.dx, + offset.dy, + ); + } + + showEmojiPickerMenu( + container, + editorState, + alignment, + offset, + ); + + return KeyEventResult.handled; +}; diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 5a38b20f22dac..85edb513ff683 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.4.7 +version: 0.4.8 environment: flutter: ">=3.18.0-0.2.pre" diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 83afac352b886..967e511bced55 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -540,6 +540,16 @@ "empty": "Is empty", "notEmpty": "Is not empty" }, + "numberFilter": { + "equal": "Equals", + "notEqual": "Does not equal", + "lessThan": "Is less than", + "greaterThan": "Is greater than", + "lessThanOrEqualTo": "Is less than or equal to", + "greaterThanOrEqualTo": "Is greater than or equal to", + "isEmpty": "Is empty", + "isNotEmpty": "Is not empty" + }, "field": { "hide": "Hide", "show": "Show", @@ -1262,4 +1272,4 @@ "userIcon": "User icon" }, "noLogFiles": "There're no log files" -} +} \ No newline at end of file diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index 5ba443feca4c8..7f2176ee8ac4d 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -152,7 +152,7 @@ "defaultNewPageName": "Sem título", "renameDialog": "Renomear" }, - "noPagesInside": "Nenhuma página dentro", + "noPagesInside": "Sem páginas internas", "toolbar": { "undo": "Desfazer", "redo": "Refazer", @@ -192,7 +192,7 @@ "clickToHidePersonal": "Clique para ocultar a seção pessoal", "clickToHideFavorites": "Clique para ocultar a seção favorita", "addAPage": "Adicionar uma página", - "recent": "Recente" + "recent": "Recentes" }, "notifications": { "export": { @@ -221,7 +221,7 @@ "discard": "Descartar", "replace": "substituir", "insertBelow": "Inserir Abaixo", - "insertAbove": "Inserir acima", + "insertAbove": "Insira acima", "upload": "Carregar", "edit": "Editar", "delete": "Excluir", @@ -298,7 +298,7 @@ "enableEncryptPrompt": "Ative a criptografia para proteger seus dados com este segredo. Armazene-o com segurança; uma vez ativado, não pode ser desativado. Se perdidos, seus dados se tornarão irrecuperáveis. Clique para copiar", "inputEncryptPrompt": "Por favor, insira seu segredo de criptografia para", "clickToCopySecret": "Clique para copiar o segredo", - "configServerSetting": "Defina as configurações do seu servidor", + "configServerSetting": "Configure seu servidor", "configServerGuide": "Após selecionar `Quick Start`, navegue até `Settings` e depois \"Cloud Settings\" para configurar seu servidor auto-hospedado.", "inputTextFieldHint": "Seu segredo", "historicalUserList": "Histórico de login do usuário", @@ -315,7 +315,7 @@ "notifications": { "enableNotifications": { "label": "Habilitar notificações", - "hint": "Desligue para impedir que notificações locais apareçam." + "hint": "Desligue para impedir notificações locais de aparecer" } }, "appearance": { diff --git a/frontend/rust-lib/event-integration/tests/user/local_test/import_af_data_local_test.rs b/frontend/rust-lib/event-integration/tests/user/local_test/import_af_data_local_test.rs new file mode 100644 index 0000000000000..0c801c77be496 --- /dev/null +++ b/frontend/rust-lib/event-integration/tests/user/local_test/import_af_data_local_test.rs @@ -0,0 +1,50 @@ +use crate::util::unzip_history_user_db; +use event_integration::user_event::user_localhost_af_cloud; +use event_integration::EventIntegrationTest; +use flowy_core::DEFAULT_NAME; +use std::time::Duration; + +#[tokio::test] +async fn import_appflowy_data_folder_into_new_view_test() { + let import_container_name = "040_local".to_string(); + let (cleaner, user_db_path) = + unzip_history_user_db("./tests/asset", &import_container_name).unwrap(); + let (imported_af_folder_cleaner, imported_af_data_path) = + unzip_history_user_db("./tests/asset", &import_container_name).unwrap(); + + user_localhost_af_cloud().await; + let test = + EventIntegrationTest::new_with_user_data_path(user_db_path.clone(), DEFAULT_NAME.to_string()) + .await; + // In the 040_local, the structure is: + // workspace: + // view: Document1 + // view: Document2 + // view: Grid1 + // view: Grid2 + // Sleep for 2 seconds to wait for the initial workspace to be created + tokio::time::sleep(Duration::from_secs(5)).await; + + test + .import_appflowy_data( + imported_af_data_path.to_str().unwrap().to_string(), + Some(import_container_name.clone()), + ) + .await + .unwrap(); + + // after import, the structure is: + // workspace: + // view: Getting Started + // view: 040_local + // view: Document1 + // view: Document2 + // view: Grid1 + // view: Grid2 + let views = test.get_all_workspace_views().await; + assert_eq!(views.len(), 2); + assert_eq!(views[1].name, import_container_name); + + drop(cleaner); + drop(imported_af_folder_cleaner); +} diff --git a/frontend/rust-lib/event-integration/tests/user/local_test/mod.rs b/frontend/rust-lib/event-integration/tests/user/local_test/mod.rs index 78d6ca1eca34a..0f1e2e47d5716 100644 --- a/frontend/rust-lib/event-integration/tests/user/local_test/mod.rs +++ b/frontend/rust-lib/event-integration/tests/user/local_test/mod.rs @@ -1,4 +1,5 @@ mod auth_test; mod helper; +mod import_af_data_local_test; mod user_awareness_test; mod user_profile_test; diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index a046237943bd5..1e44a19b0b998 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Weak}; use tokio::sync::{Mutex, RwLock}; use tokio_stream::StreamExt; -use tracing::{debug, error, event, info, instrument}; +use tracing::{debug, error, event, info, instrument, warn}; use lib_dispatch::prelude::af_spawn; use lib_infra::box_any::BoxAny; @@ -152,56 +152,88 @@ impl UserManager { user.email ); + self.prepare_user(&session).await; + self.prepare_backup(&session).await; + // Set the token if the current cloud service using token to authenticate // Currently, only the AppFlowy cloud using token to init the client api. - if let Err(err) = self.cloud_services.set_token(&user.token) { - error!("Set token failed: {}", err); - } + // TODO(nathan): using trait to separate the init process for different cloud service + if user.authenticator.is_appflowy_cloud() { + if let Err(err) = self.cloud_services.set_token(&user.token) { + error!("Set token failed: {}", err); + } - // Subscribe the token state - let weak_cloud_services = Arc::downgrade(&self.cloud_services); - let weak_authenticate_user = Arc::downgrade(&self.authenticate_user); - let weak_pool = Arc::downgrade(&self.db_pool(user.uid)?); - let cloned_session = session.clone(); - if let Some(mut token_state_rx) = self.cloud_services.subscribe_token_state() { - event!(tracing::Level::DEBUG, "Listen token state change"); - let user_uid = user.uid; - let local_token = user.token.clone(); - af_spawn(async move { - while let Some(token_state) = token_state_rx.next().await { - debug!("Token state changed: {:?}", token_state); - match token_state { - UserTokenState::Refresh { token: new_token } => { - // Only save the token if the token is different from the current token - if new_token != local_token { - if let Some(conn) = weak_pool.upgrade().and_then(|pool| pool.get().ok()) { - // Save the new token - if let Err(err) = save_user_token(user_uid, conn, new_token) { - error!("Save user token failed: {}", err); + // Subscribe the token state + let weak_cloud_services = Arc::downgrade(&self.cloud_services); + let weak_authenticate_user = Arc::downgrade(&self.authenticate_user); + let weak_pool = Arc::downgrade(&self.db_pool(user.uid)?); + let cloned_session = session.clone(); + if let Some(mut token_state_rx) = self.cloud_services.subscribe_token_state() { + event!(tracing::Level::DEBUG, "Listen token state change"); + let user_uid = user.uid; + let local_token = user.token.clone(); + af_spawn(async move { + while let Some(token_state) = token_state_rx.next().await { + debug!("Token state changed: {:?}", token_state); + match token_state { + UserTokenState::Refresh { token: new_token } => { + // Only save the token if the token is different from the current token + if new_token != local_token { + if let Some(conn) = weak_pool.upgrade().and_then(|pool| pool.get().ok()) { + // Save the new token + if let Err(err) = save_user_token(user_uid, conn, new_token) { + error!("Save user token failed: {}", err); + } } } - } - }, - UserTokenState::Invalid => { - // Force user to sign out when the token is invalid - if let (Some(cloud_services), Some(authenticate_user), Some(conn)) = ( - weak_cloud_services.upgrade(), - weak_authenticate_user.upgrade(), - weak_pool.upgrade().and_then(|pool| pool.get().ok()), - ) { + }, + UserTokenState::Invalid => { + // Attempt to upgrade the weak reference for cloud_services + let cloud_services = match weak_cloud_services.upgrade() { + Some(cloud_services) => cloud_services, + None => { + error!("Failed to upgrade weak reference for cloud_services"); + return; // Exit early if the upgrade fails + }, + }; + + // Attempt to upgrade the weak reference for authenticate_user + let authenticate_user = match weak_authenticate_user.upgrade() { + Some(authenticate_user) => authenticate_user, + None => { + warn!("Failed to upgrade weak reference for authenticate_user"); + return; // Exit early if the upgrade fails + }, + }; + + // Attempt to upgrade the weak reference for pool and then get a connection + let conn = match weak_pool.upgrade() { + Some(pool) => match pool.get() { + Ok(conn) => conn, + Err(_) => { + warn!("Failed to get connection from pool"); + return; // Exit early if getting connection fails + }, + }, + None => { + warn!("Failed to upgrade weak reference for pool"); + return; // Exit early if the upgrade fails + }, + }; + + // If all upgrades succeed, proceed with the sign_out operation if let Err(err) = sign_out(&cloud_services, &cloned_session, &authenticate_user, conn).await { error!("Sign out when token invalid failed: {:?}", err); } - } - }, + // Force user to sign out when the token is invalid + }, + } } - } - }); + }); + } } - self.prepare_user(&session).await; - self.prepare_backup(&session).await; // Do the user data migration if needed event!(tracing::Level::INFO, "Prepare user data migration"); @@ -270,7 +302,7 @@ impl UserManager { /// /// A sign-in notification is also sent after a successful sign-in. /// - #[tracing::instrument(level = "debug", skip(self, params))] + #[tracing::instrument(level = "info", skip(self, params))] pub async fn sign_in( &self, params: SignInParams, diff --git a/project.inlang/project_id b/project.inlang/project_id index 64ebffbe6b9a3..2f6b9c75b450b 100644 --- a/project.inlang/project_id +++ b/project.inlang/project_id @@ -1 +1 @@ -1d68c3f4acb5d44d0d7dcd44277e656bab7f3d5fd2fd386d8d1095a5c98ee886 \ No newline at end of file +1d68c3f4acb5d44d0d7dcd44277e656bab7f3d5fd2fd386d8d1095a5c98ee886