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
This commit is contained in:
Lucas.Xu 2024-06-25 16:43:58 +08:00 committed by GitHub
parent b9ad2768cf
commit a8ed93054c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 642 additions and 216 deletions

View File

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

View File

@ -310,6 +310,7 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
late WorkspaceService _workspaceService;
String? _workspaceId;
late UserProfilePB userProfile;
WorkspaceSectionsListener? _listener;
@override
@ -401,6 +402,7 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
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<SpaceEvent, SpaceState> {
Future<bool> _getSpaceExpandStatus(ViewPB? space) async {
if (space == null) {
return false;
return true;
}
return getIt<KeyValueStorage>().get(KVKeys.expandedViews).then((result) {

View File

@ -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<SpaceSearchEvent, SpaceSearchState> {
SpaceSearchBloc() : super(SpaceSearchState.initial()) {
on<SpaceSearchEvent>(
(event, emit) async {
await event.when(
initial: () async {
_allViews = await ViewBackendService.getAllViews().fold(
(s) => s.items,
(_) => <ViewPB>[],
);
},
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<ViewPB> _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<ViewPB>? queryResults,
}) = _SpaceSearchState;
factory SpaceSearchState.initial() => const SpaceSearchState();
}

View File

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

View File

@ -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<MovePageMenu> createState() => _MovePageMenuState();
}
class _MovePageMenuState extends State<MovePageMenu> {
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<SpaceBloc, SpaceState>(
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<SpaceSearchBloc>().add(
SpaceSearchEvent.search(
value,
),
);
},
),
const VSpace(10),
BlocBuilder<SpaceSearchBloc, SpaceSearchState>(
builder: (context, state) {
if (state.queryResults == null) {
return Expanded(
child: _buildSpace(space),
);
}
return Expanded(
child: _buildGroupedViews(state.queryResults!),
);
},
),
],
);
},
),
);
}
Widget _buildGroupedViews(List<ViewPB> 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<ViewPB> 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;
}

View File

@ -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<SpaceBloc>(),
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<bool> isHovered;
final PropertyValueNotifier<bool> 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<ViewBloc, ViewState>(
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<SpaceSearchField> createState() => _SpaceSearchFieldState();
}
class _SpaceSearchFieldState extends State<SpaceSearchField> {
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,
),
),
);
}
}

View File

@ -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<TabsBloc>().openTab(view);
}
context.read<TabsBloc>().openPlugin(view);
},
onTertiarySelected: (context, view) =>
context.read<TabsBloc>().openTab(view),
),
),
],
@ -169,57 +173,3 @@ class _SpaceState extends State<_Space> {
context.read<SpaceBloc>().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<bool> isHovered;
final PropertyValueNotifier<bool> isExpandedNotifier;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
ViewBloc(view: space)..add(const ViewEvent.initial()),
child: BlocBuilder<ViewBloc, ViewState>(
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<TabsBloc>().openTab(view);
}
context.read<TabsBloc>().openPlugin(view);
},
onTertiarySelected: (viewContext, view) =>
context.read<TabsBloc>().openTab(view),
),
)
.toList(),
);
},
),
);
}
}

View File

@ -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<SidebarSpaceHeader> {
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<SpaceBloc>(),
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<SidebarSpaceHeader> {
);
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,
),
);
}

View File

@ -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<Widget> 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<TabsBloc>().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<bool>? isHovered;
final bool shouldRenderChildren;
final ViewItemLeftIconBuilder? leftIconBuilder;
@ -216,6 +236,7 @@ class InnerViewItem extends StatefulWidget {
final PropertyValueNotifier<bool>? isExpandedNotifier;
final List<Widget> Function(ViewPB view)? extendBuilder;
final bool Function(ViewPB view)? shouldIgnoreView;
@override
State<InnerViewItem> createState() => _InnerViewItemState();
@ -254,6 +275,8 @@ class _InnerViewItemState extends State<InnerViewItem> {
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<InnerViewItem> {
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<InnerViewItem> {
leftIconBuilder: widget.leftIconBuilder,
rightIconsBuilder: widget.rightIconsBuilder,
extendBuilder: widget.extendBuilder,
shouldIgnoreView: widget.shouldIgnoreView,
);
}).toList();
@ -300,7 +325,14 @@ class _InnerViewItemState extends State<InnerViewItem> {
_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<InnerViewItem> {
leftIconBuilder: widget.leftIconBuilder,
rightIconsBuilder: widget.rightIconsBuilder,
extendBuilder: widget.extendBuilder,
shouldIgnoreView: widget.shouldIgnoreView,
),
);
},
@ -345,37 +378,6 @@ class _InnerViewItemState extends State<InnerViewItem> {
context.read<ViewBloc>().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<ViewBloc>().add(
ViewEvent.move(
from,
to.parentViewId,
null,
fromSection,
toSection,
),
);
context.read<ViewBloc>().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<bool>? isHovered;
final ViewItemLeftIconBuilder? leftIconBuilder;
final ViewItemRightIconsBuilder? rightIconsBuilder;
final List<Widget> Function(ViewPB view)? extendBuilder;
final bool Function(ViewPB view)? shouldIgnoreView;
@override
State<SingleInnerViewItem> createState() => _SingleInnerViewItemState();
@ -435,8 +441,11 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
@override
Widget build(BuildContext context) {
final isSelected =
var isSelected =
getIt<MenuSharedState>().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<SingleInnerViewItem> {
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<ViewBloc>().add(
ViewEvent.move(
from,
toId,
null,
fromSection,
toSection,
),
);
context.read<ViewBloc>().add(
ViewEvent.updateViewVisibility(
from,
spaceType == FolderSpaceType.public,
),
);
}

View File

@ -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<SpaceBloc>().userProfile;
final workspaceId = context.read<SpaceBloc>().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),

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6">
<path d="M7.66536 14.0007C11.1632 14.0007 13.9987 11.1651 13.9987 7.66732C13.9987 4.16951 11.1632 1.33398 7.66536 1.33398C4.16756 1.33398 1.33203 4.16951 1.33203 7.66732C1.33203 11.1651 4.16756 14.0007 7.66536 14.0007Z" stroke="#171717" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.6654 14.6673L13.332 13.334" stroke="#171717" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 519 B