From dc12938ab65a94ca22ea63c8c3a8df6971b90c80 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 13 Jun 2024 14:14:18 +0800 Subject: [PATCH] feat: support switching space on mobile (#5527) --- .../presentation/home/mobile_folders.dart | 120 ++++++++++----- .../presentation/home/space/mobile_space.dart | 143 ++++++++++++++++++ .../home/space/mobile_space_header.dart | 132 ++++++++++++++++ .../home/space/mobile_space_menu.dart | 128 ++++++++++++++++ .../application/sidebar/space/space_bloc.dart | 2 +- frontend/resources/translations/en.json | 3 +- 6 files changed, 484 insertions(+), 44 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart index 5b08013429..b98999a35d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/home.dart'; import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; @@ -53,53 +54,88 @@ class MobileFolders extends StatelessWidget { state.currentWorkspace?.workspaceId ?? workspaceId, ), ); + context.read().add( + SpaceEvent.reset( + user, + state.currentWorkspace?.workspaceId ?? workspaceId, + ), + ); }, - child: BlocConsumer( - listenWhen: (p, c) => - p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, - listener: (context, state) { - final lastCreatedRootView = state.lastCreatedRootView; - if (lastCreatedRootView != null) { - context.pushView(lastCreatedRootView); - } - }, - builder: (context, state) { - final isCollaborativeWorkspace = - context.read().state.isCollabWorkspaceOn; - return SlidableAutoCloseBehavior( - child: Column( - children: [ - ...isCollaborativeWorkspace - ? [ - MobileSectionFolder( - title: LocaleKeys.sideBar_workspace.tr(), - spaceType: FolderSpaceType.public, - views: state.section.publicViews, - ), - const VSpace(8.0), - MobileSectionFolder( - title: LocaleKeys.sideBar_private.tr(), - spaceType: FolderSpaceType.private, - views: state.section.privateViews, - ), - ] - : [ - MobileSectionFolder( - title: LocaleKeys.sideBar_personal.tr(), - spaceType: FolderSpaceType.public, - views: state.section.publicViews, - ), - ], - const VSpace(4.0), - const _TrashButton(), - ], - ), - ); - }, + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedPage?.id != c.lastCreatedPage?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedPage; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedRootView; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + return SlidableAutoCloseBehavior( + child: Column( + children: [ + ..._buildSpaceOrSection(context, state), + const VSpace(4.0), + const _TrashButton(), + ], + ), + ); + }, + ), ), ), ); } + + List _buildSpaceOrSection( + BuildContext context, + SidebarSectionsState state, + ) { + if (context.watch().state.spaces.isNotEmpty) { + return [ + const MobileSpace(), + ]; + } + + if (context.read().state.isCollabWorkspaceOn) { + return [ + MobileSectionFolder( + title: LocaleKeys.sideBar_workspace.tr(), + spaceType: FolderSpaceType.public, + views: state.section.publicViews, + ), + const VSpace(8.0), + MobileSectionFolder( + title: LocaleKeys.sideBar_private.tr(), + spaceType: FolderSpaceType.private, + views: state.section.privateViews, + ), + ]; + } + + return [ + MobileSectionFolder( + title: LocaleKeys.sideBar_personal.tr(), + spaceType: FolderSpaceType.public, + views: state.section.publicViews, + ), + ]; + } } class _TrashButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart new file mode 100644 index 0000000000..fd3b45cbd4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -0,0 +1,143 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.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_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSpace extends StatefulWidget { + const MobileSpace({super.key}); + + @override + State createState() => _MobileSpaceState(); +} + +class _MobileSpaceState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty) { + return const SizedBox.shrink(); + } + + final currentSpace = state.currentSpace ?? state.spaces.first; + + return Column( + children: [ + MobileSpaceHeader( + isExpanded: state.isExpanded, + space: currentSpace, + onAdded: () { + context.read().add( + SpaceEvent.createPage( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + index: 0, + viewSection: currentSpace.spacePermission == + SpacePermission.publicToAll + ? ViewSectionPB.Public + : ViewSectionPB.Private, + ), + ); + context.read().add( + SpaceEvent.expand(currentSpace, true), + ); + }, + onPressed: () => _showSpaceMenu(context), + ), + _Pages( + key: ValueKey(currentSpace.id), + space: currentSpace, + ), + ], + ); + }, + ); + } + + void _showSpaceMenu(BuildContext context) { + showMobileBottomSheet( + context, + showDivider: false, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + showDoneButton: true, + title: LocaleKeys.space_title.tr(), + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: MobileSpaceMenu(), + ), + ); + }, + ); + } +} + +class _Pages extends StatelessWidget { + const _Pages({ + super.key, + required this.space, + }); + + final ViewPB space; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + ViewBloc(view: space)..add(const ViewEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final spaceType = space.spacePermission == SpacePermission.publicToAll + ? FolderSpaceType.public + : FolderSpaceType.private; + return Column( + children: state.view.childViews + .map( + (view) => MobileViewItem( + key: ValueKey( + '${space.id} ${view.id}', + ), + spaceType: spaceType, + isFirstChild: view.id == state.view.childViews.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + onSelected: context.pushView, + endActionPane: (context) { + final view = context.read().state.view; + return buildEndActionPane( + context, + [ + MobilePaneActionType.more, + if (view.layout == ViewLayoutPB.Document) + MobilePaneActionType.add, + ], + spaceType: spaceType, + needSpace: false, + ); + }, + ), + ) + .toList(), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart new file mode 100644 index 0000000000..ffc1691404 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +@visibleForTesting +const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey'); + +class MobileSpaceHeader extends StatelessWidget { + const MobileSpaceHeader({ + super.key, + required this.space, + required this.onPressed, + required this.onAdded, + required this.isExpanded, + }); + + final ViewPB space; + final VoidCallback onPressed; + final VoidCallback onAdded; + final bool isExpanded; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onPressed, + child: SizedBox( + height: 48, + child: Row( + children: [ + SpaceIcon( + dimension: 24, + space: space, + cornerRadius: 6.0, + ), + const HSpace(8), + FlowyText.medium( + space.name, + lineHeight: 1.15, + fontSize: 16.0, + ), + const HSpace(4.0), + const FlowySvg( + FlowySvgs.workspace_drop_down_menu_show_s, + ), + const Spacer(), + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onAdded, + child: const FlowySvg( + FlowySvgs.m_space_add_s, + ), + ), + ], + ), + ), + ); + } + + // Future _onAction(SpaceMoreActionType type, dynamic data) async { + // switch (type) { + // case SpaceMoreActionType.rename: + // await _showRenameDialog(); + // break; + // case SpaceMoreActionType.changeIcon: + // final (String icon, String iconColor) = data; + // context.read().add(SpaceEvent.changeIcon(icon, iconColor)); + // break; + // case SpaceMoreActionType.manage: + // _showManageSpaceDialog(context); + // break; + // case SpaceMoreActionType.addNewSpace: + // break; + // case SpaceMoreActionType.collapseAllPages: + // break; + // case SpaceMoreActionType.delete: + // _showDeleteSpaceDialog(context); + // break; + // case SpaceMoreActionType.divider: + // break; + // } + // } + + // Future _showRenameDialog() async { + // await NavigatorTextFieldDialog( + // title: LocaleKeys.space_rename.tr(), + // value: space.name, + // autoSelectAllText: true, + // onConfirm: (name, _) { + // context.read().add(SpaceEvent.rename(space, name)); + // }, + // ).show(context); + // } + + // void _showManageSpaceDialog(BuildContext context) { + // final spaceBloc = context.read(); + // showDialog( + // context: context, + // builder: (_) { + // return Dialog( + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(12.0), + // ), + // child: BlocProvider.value( + // value: spaceBloc, + // child: const ManageSpacePopup(), + // ), + // ); + // }, + // ); + // } + + // void _showDeleteSpaceDialog(BuildContext context) { + // final spaceBloc = context.read(); + // showDialog( + // context: context, + // builder: (_) { + // return Dialog( + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(12.0), + // ), + // child: BlocProvider.value( + // value: spaceBloc, + // child: const SizedBox(width: 440, child: DeleteSpacePopup()), + // ), + // ); + // }, + // ); + // } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart new file mode 100644 index 0000000000..6788e1396d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -0,0 +1,128 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSpaceMenu extends StatelessWidget { + const MobileSpaceMenu({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(4.0), + for (final space in state.spaces) + SizedBox( + height: 52, + child: _SidebarSpaceMenuItem( + space: space, + isSelected: state.currentSpace?.id == space.id, + ), + ), + // const Padding( + // padding: EdgeInsets.symmetric(vertical: 8.0), + // child: Divider( + // height: 0.5, + // ), + // ), + // const SizedBox( + // height: 52, + // child: _CreateSpaceButton(), + // ), + ], + ); + }, + ); + } +} + +class _SidebarSpaceMenuItem extends StatelessWidget { + const _SidebarSpaceMenuItem({ + required this.space, + required this.isSelected, + }); + + final ViewPB space; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return FlowyButton( + text: Row( + children: [ + FlowyText.medium( + space.name, + ), + const HSpace(6.0), + if (space.spacePermission == SpacePermission.private) + const FlowySvg( + FlowySvgs.space_lock_s, + size: Size.square(12), + ), + ], + ), + iconPadding: 10, + leftIcon: SpaceIcon( + dimension: 24, + space: space, + cornerRadius: 6.0, + ), + leftIconSize: const Size.square(20), + rightIcon: isSelected + ? const FlowySvg( + FlowySvgs.workspace_selected_s, + blendMode: null, + ) + : null, + onTap: () { + context.read().add(SpaceEvent.open(space)); + Navigator.of(context).pop(); + }, + ); + } +} + +// class _CreateSpaceButton extends StatelessWidget { +// const _CreateSpaceButton(); + +// @override +// Widget build(BuildContext context) { +// return FlowyButton( +// text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()), +// iconPadding: 10, +// leftIcon: const FlowySvg( +// FlowySvgs.space_add_s, +// ), +// leftIconSize: const Size.square(20), +// onTap: () { +// PopoverContainer.of(context).close(); +// _showCreateSpaceDialog(context); +// }, +// ); +// } + +// void _showCreateSpaceDialog(BuildContext context) { +// final spaceBloc = context.read(); +// showDialog( +// context: context, +// builder: (_) { +// return Dialog( +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(12.0), +// ), +// child: BlocProvider.value( +// value: spaceBloc, +// child: const CreateSpacePopup(), +// ), +// ); +// }, +// ); +// } +// } 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 f2c02b6b7f..8d3e8c557d 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 @@ -332,7 +332,7 @@ class SpaceBloc extends Bloc { final spaceId = await getIt().get(KVKeys.lastOpenedSpaceId); if (spaceId == null) { - return null; + return spaces.first; } final space = diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index cf54b06abf..4c00612fec 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1911,7 +1911,8 @@ "dangerZone": "Danger Zone", "unableToDeleteLastSpace": "Cannot delete the last space", "unableToDeleteSpaceNotCreatedByYou": "Cannot delete a space created by others", - "enableSpacesForYourWorkspace": "Enable spaces for your workspace" + "enableSpacesForYourWorkspace": "Enable spaces for your workspace", + "title": "Spaces" } }