From a8ed93054c9b08ec5a35707dae412fe684d00d94 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 25 Jun 2024 16:43:58 +0800 Subject: [PATCH] feat: support moving page across spaces (#5618) * feat: support moving page across spaces * feat: refacotor move api * feat: filter the database views * feat: support searching in move page menu --- .../sidebar/folder/folder_bloc.dart | 4 +- .../application/sidebar/space/space_bloc.dart | 4 +- .../sidebar/space/space_search_bloc.dart | 59 +++++ .../menu/sidebar/favorites/favorite_menu.dart | 73 +----- .../menu/sidebar/move_to/move_page_menu.dart | 190 ++++++++++++++++ .../menu/sidebar/space/shared_widget.dart | 215 ++++++++++++++++++ .../menu/sidebar/space/sidebar_space.dart | 70 +----- .../sidebar/space/sidebar_space_header.dart | 48 +--- .../home/menu/view/view_item.dart | 134 ++++++++--- .../menu/view/view_more_action_button.dart | 55 ++++- .../resources/flowy_icons/16x/magnifier.svg | 6 + 11 files changed, 642 insertions(+), 216 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart create mode 100644 frontend/resources/flowy_icons/16x/magnifier.svg diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart index c85f3bd0b095..609b9ce0ae4f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart @@ -12,7 +12,8 @@ part 'folder_bloc.freezed.dart'; enum FolderSpaceType { favorite, private, - public; + public, + unknown; ViewSectionPB get toViewSectionPB { switch (this) { @@ -21,6 +22,7 @@ enum FolderSpaceType { case FolderSpaceType.public: return ViewSectionPB.Public; case FolderSpaceType.favorite: + case FolderSpaceType.unknown: throw UnimplementedError(); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index 3fd4afd4877e..e490e75cdf89 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -310,6 +310,7 @@ class SpaceBloc extends Bloc { late WorkspaceService _workspaceService; String? _workspaceId; + late UserProfilePB userProfile; WorkspaceSectionsListener? _listener; @override @@ -401,6 +402,7 @@ class SpaceBloc extends Bloc { void _initial(UserProfilePB userProfile, String workspaceId) { _workspaceService = WorkspaceService(workspaceId: workspaceId); _workspaceId = workspaceId; + this.userProfile = userProfile; _listener = WorkspaceSectionsListener( user: userProfile, @@ -461,7 +463,7 @@ class SpaceBloc extends Bloc { Future _getSpaceExpandStatus(ViewPB? space) async { if (space == null) { - return false; + return true; } return getIt().get(KVKeys.expandedViews).then((result) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart new file mode 100644 index 000000000000..04e3ad789664 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'space_search_bloc.freezed.dart'; + +class SpaceSearchBloc extends Bloc { + SpaceSearchBloc() : super(SpaceSearchState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + _allViews = await ViewBackendService.getAllViews().fold( + (s) => s.items, + (_) => [], + ); + }, + search: (query) { + if (query.isEmpty) { + emit( + state.copyWith( + queryResults: null, + ), + ); + } else { + final queryResults = _allViews.where( + (view) => view.name.toLowerCase().contains(query.toLowerCase()), + ); + emit( + state.copyWith( + queryResults: queryResults.toList(), + ), + ); + } + }, + ); + }, + ); + } + + late final List _allViews; +} + +@freezed +class SpaceSearchEvent with _$SpaceSearchEvent { + const factory SpaceSearchEvent.initial() = _Initial; + const factory SpaceSearchEvent.search(String query) = _Search; +} + +@freezed +class SpaceSearchState with _$SpaceSearchState { + const factory SpaceSearchState({ + List? queryResults, + }) = _SpaceSearchState; + + factory SpaceSearchState.initial() => const SpaceSearchState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart index 71c36a06197a..4449558bb65f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart @@ -6,12 +6,12 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/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/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -41,7 +41,7 @@ class FavoriteMenu extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const VSpace(4), - _FavoriteSearchField( + SpaceSearchField( width: minWidth - 2 * _kHorizontalPadding, onSearch: (context, text) { context @@ -197,72 +197,3 @@ class _FavoriteGroups extends StatelessWidget { ]; } } - -class _FavoriteSearchField extends StatefulWidget { - const _FavoriteSearchField({ - required this.width, - required this.onSearch, - }); - - final double width; - final void Function(BuildContext context, String text) onSearch; - - @override - State<_FavoriteSearchField> createState() => _FavoriteSearchFieldState(); -} - -class _FavoriteSearchFieldState extends State<_FavoriteSearchField> { - final focusNode = FocusNode(); - - @override - void initState() { - super.initState(); - focusNode.requestFocus(); - } - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - height: 30, - width: widget.width, - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - side: const BorderSide( - width: 1.20, - strokeAlign: BorderSide.strokeAlignOutside, - color: Color(0xFF00BCF0), - ), - borderRadius: BorderRadius.circular(8), - ), - ), - child: CupertinoSearchTextField( - onChanged: (text) => widget.onSearch(context, text), - padding: EdgeInsets.zero, - focusNode: focusNode, - placeholder: LocaleKeys.search_label.tr(), - prefixIcon: const FlowySvg(FlowySvgs.m_search_m), - prefixInsets: const EdgeInsets.only(left: 12.0, right: 8.0), - suffixIcon: const Icon(Icons.close), - suffixInsets: const EdgeInsets.only(right: 8.0), - itemSize: 16.0, - decoration: const BoxDecoration( - color: Colors.transparent, - ), - placeholderStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).hintColor, - fontWeight: FontWeight.w400, - ), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w400, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart new file mode 100644 index 000000000000..36352864e8d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart @@ -0,0 +1,190 @@ +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_search_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MovePageMenu extends StatefulWidget { + const MovePageMenu({ + super.key, + required this.sourceView, + required this.userProfile, + required this.workspaceId, + required this.onSelected, + }); + + final ViewPB sourceView; + final UserProfilePB userProfile; + final String workspaceId; + final void Function(ViewPB view) onSelected; + + @override + State createState() => _MovePageMenuState(); +} + +class _MovePageMenuState extends State { + final isExpandedNotifier = PropertyValueNotifier(true); + final isHoveredNotifier = ValueNotifier(true); + + @override + void dispose() { + isExpandedNotifier.dispose(); + isHoveredNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SpaceBloc() + ..add( + SpaceEvent.initial( + widget.userProfile, + widget.workspaceId, + openFirstPage: false, + ), + ), + ), + BlocProvider( + create: (context) => SpaceSearchBloc() + ..add( + const SpaceSearchEvent.initial(), + ), + ), + ], + child: BlocBuilder( + builder: (context, state) { + final space = state.currentSpace; + if (space == null) { + return const SizedBox.shrink(); + } + return Column( + children: [ + SpaceSearchField( + width: 240, + onSearch: (context, value) { + context.read().add( + SpaceSearchEvent.search( + value, + ), + ); + }, + ), + const VSpace(10), + BlocBuilder( + builder: (context, state) { + if (state.queryResults == null) { + return Expanded( + child: _buildSpace(space), + ); + } + return Expanded( + child: _buildGroupedViews(state.queryResults!), + ); + }, + ), + ], + ); + }, + ), + ); + } + + Widget _buildGroupedViews(List views) { + final groupedViews = views + .where( + (view) => + !_shouldIgnoreView(view, widget.sourceView) && !view.isSpace, + ) + .toList(); + return _MovePageGroupedViews( + views: groupedViews, + onSelected: widget.onSelected, + ); + } + + Column _buildSpace(ViewPB space) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SpacePopup( + child: CurrentSpace( + space: space, + ), + ), + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: SpacePages( + key: ValueKey(space.id), + space: space, + isHovered: isHoveredNotifier, + isExpandedNotifier: isExpandedNotifier, + shouldIgnoreView: (view) => _shouldIgnoreView( + view, + widget.sourceView, + ), + // hide the hover status and disable the editing actions + disableSelectedStatus: true, + // hide the ... and + buttons + rightIconsBuilder: (context, view) => [], + onSelected: (_, view) => widget.onSelected(view), + ), + ), + ), + ], + ); + } +} + +class _MovePageGroupedViews extends StatelessWidget { + const _MovePageGroupedViews({ + required this.views, + required this.onSelected, + }); + + final List views; + final void Function(ViewPB view) onSelected; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: views + .map( + (e) => ViewItem( + key: ValueKey(e.id), + view: e, + spaceType: FolderSpaceType.unknown, + level: 0, + onSelected: (_, view) => onSelected(view), + isFeedback: false, + isDraggable: false, + shouldRenderChildren: false, + leftIconBuilder: (_, __) => const HSpace(0.0), + rightIconsBuilder: (_, view) => [], + ), + ) + .toList(), + ), + ); + } +} + +bool _shouldIgnoreView(ViewPB view, ViewPB sourceView) { + // ignore the source view and database view, don't render it in the list. + if (view.layout != ViewLayoutPB.Document) { + return true; + } + return view.id == sourceView.id; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index d4d7ab677268..a657bc8fdea5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -1,10 +1,21 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.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:flowy_infra_ui/style_widget/decoration.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -262,3 +273,207 @@ class DeleteSpacePopup extends StatelessWidget { ); } } + +class SpacePopup extends StatelessWidget { + const SpacePopup({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: HomeSizes.workspaceSectionHeight, + child: AppFlowyPopover( + constraints: const BoxConstraints(maxWidth: 260), + direction: PopoverDirection.bottomWithLeftAligned, + clickHandler: PopoverClickHandler.gestureDetector, + offset: const Offset(0, 4), + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const SidebarSpaceMenu(), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.only(left: 3.0, right: 4.0), + iconPadding: 10.0, + text: child, + ), + ), + ); + } +} + +class CurrentSpace extends StatelessWidget { + const CurrentSpace({ + super.key, + required this.space, + }); + + final ViewPB space; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SpaceIcon( + dimension: 20, + space: space, + cornerRadius: 6.0, + ), + const HSpace(10), + Flexible( + child: FlowyText.medium( + space.name, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4.0), + const FlowySvg( + FlowySvgs.workspace_drop_down_menu_show_s, + ), + ], + ); + } +} + +class SpacePages extends StatelessWidget { + const SpacePages({ + super.key, + required this.space, + required this.isHovered, + required this.isExpandedNotifier, + required this.onSelected, + this.rightIconsBuilder, + this.disableSelectedStatus = false, + this.onTertiarySelected, + this.shouldIgnoreView, + }); + + final ViewPB space; + final ValueNotifier isHovered; + final PropertyValueNotifier isExpandedNotifier; + final bool disableSelectedStatus; + final ViewItemRightIconsBuilder? rightIconsBuilder; + final ViewItemOnSelected onSelected; + final ViewItemOnSelected? onTertiarySelected; + final bool Function(ViewPB view)? shouldIgnoreView; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + ViewBloc(view: space)..add(const ViewEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // filter the child views that should be ignored + var childViews = state.view.childViews; + if (shouldIgnoreView != null) { + childViews = childViews + .where((childView) => !shouldIgnoreView!(childView)) + .toList(); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: childViews + .map( + (view) => ViewItem( + key: ValueKey('${space.id} ${view.id}'), + spaceType: + space.spacePermission == SpacePermission.publicToAll + ? FolderSpaceType.public + : FolderSpaceType.private, + isFirstChild: view.id == childViews.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + isHovered: isHovered, + disableSelectedStatus: disableSelectedStatus, + isExpandedNotifier: isExpandedNotifier, + rightIconsBuilder: rightIconsBuilder, + onSelected: onSelected, + onTertiarySelected: onTertiarySelected, + shouldIgnoreView: shouldIgnoreView, + ), + ) + .toList(), + ); + }, + ), + ); + } +} + +class SpaceSearchField extends StatefulWidget { + const SpaceSearchField({ + super.key, + required this.width, + required this.onSearch, + }); + + final double width; + final void Function(BuildContext context, String text) onSearch; + + @override + State createState() => _SpaceSearchFieldState(); +} + +class _SpaceSearchFieldState extends State { + final focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + focusNode.requestFocus(); + } + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 30, + width: widget.width, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.20, + strokeAlign: BorderSide.strokeAlignOutside, + color: Color(0xFF00BCF0), + ), + borderRadius: BorderRadius.circular(8), + ), + ), + child: CupertinoSearchTextField( + onChanged: (text) => widget.onSearch(context, text), + padding: EdgeInsets.zero, + focusNode: focusNode, + placeholder: LocaleKeys.search_label.tr(), + prefixIcon: const FlowySvg(FlowySvgs.magnifier_s), + prefixInsets: const EdgeInsets.only(left: 12.0, right: 8.0), + suffixIcon: const Icon(Icons.close), + suffixInsets: const EdgeInsets.only(right: 8.0), + itemSize: 16.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + placeholderStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w400, + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart index e5fe8d259633..08c35e940ac0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart @@ -1,19 +1,15 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_bloc.dart'; -import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/rename_view_dialog.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart'; -import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -115,11 +111,19 @@ class _SpaceState extends State<_Space> { MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, - child: _Pages( + child: SpacePages( key: ValueKey(currentSpace.id), isExpandedNotifier: isExpandedNotifier, space: currentSpace, isHovered: isHovered, + onSelected: (context, view) { + if (HardwareKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + context.read().openPlugin(view); + }, + onTertiarySelected: (context, view) => + context.read().openTab(view), ), ), ], @@ -169,57 +173,3 @@ class _SpaceState extends State<_Space> { context.read().add(const SpaceEvent.switchToNextSpace()); } } - -class _Pages extends StatelessWidget { - const _Pages({ - super.key, - required this.space, - required this.isHovered, - required this.isExpandedNotifier, - }); - - final ViewPB space; - final ValueNotifier isHovered; - final PropertyValueNotifier isExpandedNotifier; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - ViewBloc(view: space)..add(const ViewEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return Column( - children: state.view.childViews - .map( - (view) => ViewItem( - key: ValueKey('${space.id} ${view.id}'), - spaceType: - space.spacePermission == SpacePermission.publicToAll - ? FolderSpaceType.public - : FolderSpaceType.private, - isFirstChild: view.id == state.view.childViews.first.id, - view: view, - level: 0, - leftPadding: HomeSpaceViewSizes.leftPadding, - isFeedback: false, - isHovered: isHovered, - isExpandedNotifier: isExpandedNotifier, - onSelected: (viewContext, view) { - if (HardwareKeyboard.instance.isControlPressed) { - context.read().openTab(view); - } - - context.read().openPlugin(view); - }, - onTertiarySelected: (viewContext, view) => - context.read().openTab(view), - ), - ) - .toList(), - ); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart index 65ad86d77cbb..f4f4c694e5a3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart @@ -7,13 +7,10 @@ import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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:flowy_infra_ui/style_widget/hover.dart'; @@ -68,24 +65,8 @@ class _SidebarSpaceHeaderState extends State { left: 3, top: 3, bottom: 3, - child: SizedBox( - height: HomeSizes.workspaceSectionHeight, - child: AppFlowyPopover( - constraints: const BoxConstraints(maxWidth: 252), - direction: PopoverDirection.bottomWithLeftAligned, - clickHandler: PopoverClickHandler.gestureDetector, - offset: const Offset(0, 4), - popupBuilder: (_) => BlocProvider.value( - value: context.read(), - child: const SidebarSpaceMenu(), - ), - child: FlowyButton( - useIntrinsicWidth: true, - margin: const EdgeInsets.only(left: 3.0, right: 4.0), - iconPadding: 10.0, - text: _buildChild(), - ), - ), + child: SpacePopup( + child: _buildChild(), ), ), Positioned( @@ -135,29 +116,8 @@ class _SidebarSpaceHeaderState extends State { ); return FlowyTooltip( richMessage: textSpan, - child: Row( - children: [ - SpaceIcon( - dimension: 20, - space: widget.space, - cornerRadius: 6.0, - ), - const HSpace(10), - Flexible( - child: FlowyText.medium( - widget.space.name, - lineHeight: 1.15, - fontSize: 14.0, - overflow: TextOverflow.ellipsis, - ), - ), - const HSpace(4.0), - FlowySvg( - widget.isExpanded - ? FlowySvgs.workspace_drop_down_menu_show_s - : FlowySvgs.workspace_drop_down_menu_hide_s, - ), - ], + child: CurrentSpace( + space: widget.space, ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 290f5ba4e0ac..ac2f7158e639 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -52,7 +52,7 @@ class ViewItem extends StatelessWidget { this.isDraggable = true, required this.isFeedback, this.height = HomeSpaceViewSizes.viewHeight, - this.isHoverEnabled = true, + this.isHoverEnabled = false, this.isPlaceholder = false, this.isHovered, this.shouldRenderChildren = true, @@ -61,6 +61,8 @@ class ViewItem extends StatelessWidget { this.shouldLoadChildViews = true, this.isExpandedNotifier, this.extendBuilder, + this.disableSelectedStatus, + this.shouldIgnoreView, }); final ViewPB view; @@ -116,6 +118,12 @@ class ViewItem extends StatelessWidget { final List Function(ViewPB view)? extendBuilder; + // disable the selected status of the view item + final bool? disableSelectedStatus; + + // ignore the views when rendering the child views + final bool Function(ViewPB view)? shouldIgnoreView; + @override Widget build(BuildContext context) { return BlocProvider( @@ -129,15 +137,23 @@ class ViewItem extends StatelessWidget { listener: (context, state) => context.read().openPlugin(state.lastCreatedView!), builder: (context, state) { + // filter the child views that should be ignored + var childViews = state.view.childViews; + if (shouldIgnoreView != null) { + childViews = childViews + .where((childView) => !shouldIgnoreView!(childView)) + .toList(); + } return InnerViewItem( view: state.view, parentView: parentView, - childViews: state.view.childViews, + childViews: childViews, spaceType: spaceType, level: level, leftPadding: leftPadding, showActions: state.isEditing, isExpanded: state.isExpanded, + disableSelectedStatus: disableSelectedStatus, onSelected: onSelected, onTertiarySelected: onTertiarySelected, isFirstChild: isFirstChild, @@ -152,6 +168,7 @@ class ViewItem extends StatelessWidget { rightIconsBuilder: rightIconsBuilder, isExpandedNotifier: isExpandedNotifier, extendBuilder: extendBuilder, + shouldIgnoreView: shouldIgnoreView, ); }, ), @@ -186,6 +203,8 @@ class InnerViewItem extends StatefulWidget { required this.rightIconsBuilder, this.isExpandedNotifier, required this.extendBuilder, + this.disableSelectedStatus, + required this.shouldIgnoreView, }); final ViewPB view; @@ -209,6 +228,7 @@ class InnerViewItem extends StatefulWidget { final bool isHoverEnabled; final bool isPlaceholder; + final bool? disableSelectedStatus; final ValueNotifier? isHovered; final bool shouldRenderChildren; final ViewItemLeftIconBuilder? leftIconBuilder; @@ -216,6 +236,7 @@ class InnerViewItem extends StatefulWidget { final PropertyValueNotifier? isExpandedNotifier; final List Function(ViewPB view)? extendBuilder; + final bool Function(ViewPB view)? shouldIgnoreView; @override State createState() => _InnerViewItemState(); @@ -254,6 +275,8 @@ class _InnerViewItemState extends State { leftIconBuilder: widget.leftIconBuilder, rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, + disableSelectedStatus: widget.disableSelectedStatus, + shouldIgnoreView: widget.shouldIgnoreView, ); // if the view is expanded and has child views, render its child views @@ -271,6 +294,7 @@ class _InnerViewItemState extends State { onSelected: widget.onSelected, onTertiarySelected: widget.onTertiarySelected, isDraggable: widget.isDraggable, + disableSelectedStatus: widget.disableSelectedStatus, leftPadding: widget.leftPadding, isFeedback: widget.isFeedback, isPlaceholder: widget.isPlaceholder, @@ -278,6 +302,7 @@ class _InnerViewItemState extends State { leftIconBuilder: widget.leftIconBuilder, rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, + shouldIgnoreView: widget.shouldIgnoreView, ); }).toList(); @@ -300,7 +325,14 @@ class _InnerViewItemState extends State { _isDragging = isDragging; }, onMove: widget.isPlaceholder - ? (from, to) => _moveViewCrossSection(context, from, to) + ? (from, to) => _moveViewCrossSection( + context, + widget.view, + widget.parentView, + widget.spaceType, + from, + to.parentViewId, + ) : null, feedback: (context) { return Container( @@ -324,6 +356,7 @@ class _InnerViewItemState extends State { leftIconBuilder: widget.leftIconBuilder, rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, + shouldIgnoreView: widget.shouldIgnoreView, ), ); }, @@ -345,37 +378,6 @@ class _InnerViewItemState extends State { context.read().add(const ViewEvent.collapseAllPages()); } } - - void _moveViewCrossSection( - BuildContext context, - ViewPB from, - ViewPB to, - ) { - if (isReferencedDatabaseView(widget.view, widget.parentView)) { - return; - } - final fromSection = widget.spaceType == FolderSpaceType.public - ? ViewSectionPB.Private - : ViewSectionPB.Public; - final toSection = widget.spaceType == FolderSpaceType.public - ? ViewSectionPB.Public - : ViewSectionPB.Private; - context.read().add( - ViewEvent.move( - from, - to.parentViewId, - null, - fromSection, - toSection, - ), - ); - context.read().add( - ViewEvent.updateViewVisibility( - from, - widget.spaceType == FolderSpaceType.public, - ), - ); - } } class SingleInnerViewItem extends StatefulWidget { @@ -399,6 +401,8 @@ class SingleInnerViewItem extends StatefulWidget { required this.leftIconBuilder, required this.rightIconsBuilder, required this.extendBuilder, + required this.disableSelectedStatus, + required this.shouldIgnoreView, }); final ViewPB view; @@ -419,11 +423,13 @@ class SingleInnerViewItem extends StatefulWidget { final bool isHoverEnabled; final bool isPlaceholder; + final bool? disableSelectedStatus; final ValueNotifier? isHovered; final ViewItemLeftIconBuilder? leftIconBuilder; final ViewItemRightIconsBuilder? rightIconsBuilder; final List Function(ViewPB view)? extendBuilder; + final bool Function(ViewPB view)? shouldIgnoreView; @override State createState() => _SingleInnerViewItemState(); @@ -435,8 +441,11 @@ class _SingleInnerViewItemState extends State { @override Widget build(BuildContext context) { - final isSelected = + var isSelected = getIt().latestOpenView?.id == widget.view.id; + if (widget.disableSelectedStatus == true) { + isSelected = false; + } if (widget.isPlaceholder) { return const SizedBox( @@ -714,6 +723,22 @@ class _SingleInnerViewItemState extends State { iconType: result.type.toProto(), ); break; + case ViewMoreActionType.moveTo: + final target = data; + if (target is! ViewPB) { + return; + } + debugPrint( + 'Move view ${widget.view.id}, ${widget.view.name} to ${target.id}, ${target.name}', + ); + _moveViewCrossSection( + context, + widget.view, + widget.parentView, + widget.spaceType, + widget.view, + target.id, + ); default: throw UnsupportedError('$action is not supported'); } @@ -765,3 +790,42 @@ bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) { } return view.layout.isDatabaseView && parentView.layout.isDatabaseView; } + +void _moveViewCrossSection( + BuildContext context, + ViewPB view, + ViewPB? parentView, + FolderSpaceType spaceType, + ViewPB from, + String toId, +) { + if (isReferencedDatabaseView(view, parentView)) { + return; + } + + if (from.id == toId) { + return; + } + + final fromSection = spaceType == FolderSpaceType.public + ? ViewSectionPB.Private + : ViewSectionPB.Public; + final toSection = spaceType == FolderSpaceType.public + ? ViewSectionPB.Public + : ViewSectionPB.Private; + context.read().add( + ViewEvent.move( + from, + toId, + null, + fromSection, + toSection, + ), + ); + context.read().add( + ViewEvent.updateViewVisibility( + from, + spaceType == FolderSpaceType.public, + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart index a5ef7e670365..5e0107ce73ec 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -1,12 +1,15 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; /// ยทยทยท button beside the view name class ViewMoreActionButton extends StatelessWidget { @@ -52,7 +55,7 @@ class ViewMoreActionButton extends StatelessWidget { final actionTypes = _buildActionTypes(); return actionTypes .map( - (e) => ViewMoreActionTypeWrapper(e, (controller, data) { + (e) => ViewMoreActionTypeWrapper(e, view, (controller, data) { onEditing(false); onAction(e, data); controller.close(); @@ -92,6 +95,7 @@ class ViewMoreActionButton extends StatelessWidget { } actionTypes.addAll([ + ViewMoreActionType.moveTo, ViewMoreActionType.delete, ViewMoreActionType.divider, ]); @@ -110,9 +114,14 @@ class ViewMoreActionButton extends StatelessWidget { } class ViewMoreActionTypeWrapper extends CustomActionCell { - ViewMoreActionTypeWrapper(this.inner, this.onTap); + ViewMoreActionTypeWrapper( + this.inner, + this.sourceView, + this.onTap, + ); final ViewMoreActionType inner; + final ViewPB sourceView; final void Function(PopoverController controller, dynamic data) onTap; @override @@ -125,9 +134,11 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { return _buildCreated(context); } else if (inner == ViewMoreActionType.changeIcon) { return _buildEmojiActionButton(context, controller); - } else { - return _buildNormalActionButton(context, controller); + } else if (inner == ViewMoreActionType.moveTo) { + return _buildMoveToActionButton(context, controller); } + + return _buildNormalActionButton(context, controller); } Widget _buildNormalActionButton( @@ -154,6 +165,42 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { ); } + Widget _buildMoveToActionButton( + BuildContext context, + PopoverController controller, + ) { + final child = _buildActionButton(context, null); + final userProfile = context.read().userProfile; + final workspaceId = context.read().state.currentSpace?.id; + + return AppFlowyPopover( + constraints: const BoxConstraints( + maxWidth: 260, + maxHeight: 345, + ), + margin: const EdgeInsets.symmetric( + horizontal: 14.0, + vertical: 12.0, + ), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) { + if (workspaceId == null) { + return const SizedBox(); + } + + return MovePageMenu( + sourceView: sourceView, + userProfile: userProfile, + workspaceId: workspaceId, + onSelected: (view) { + onTap(controller, view); + }, + ); + }, + child: child, + ); + } + Widget _buildDivider() { return const Padding( padding: EdgeInsets.all(8.0), diff --git a/frontend/resources/flowy_icons/16x/magnifier.svg b/frontend/resources/flowy_icons/16x/magnifier.svg new file mode 100644 index 000000000000..60c7ae8d5fbd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/magnifier.svg @@ -0,0 +1,6 @@ + + + + + +