From 2d674060c64749414f4521a238c6c2e1cba705a6 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 13 Jun 2024 13:43:29 +0800 Subject: [PATCH] feat: introduce space to manage the pages (#5517) * fix: resizing icon on mobile * feat: add space feature * feat: support creating space * feat: support creating new space * feat: support space expand status * feat: support creating page in space * feat: support customizing space icon * feat: display the space icon on space menu * feat: add space more action button * fix: flutter analyze * feat: support editing space icon on more menu * chore: update translations * feat: manage space * feat: delete workspace * feat: disable delete button if needed * feat: add private lock * chore: adjust the old version * feat: display upgrade button * feat: support migrating space * feat: support migrating space * feat: allow user to upgrade space maunally * fix: dark mode issue * fix: create space delay * chore: translations * chore: disable workspace test --- .../collaborative_workspace_test.dart | 98 ++-- frontend/appflowy_flutter/ios/Podfile.lock | 4 +- .../lib/core/config/kv_keys.dart | 14 +- .../default_mobile_action_pane.dart | 1 - .../presentation/home/mobile_folders.dart | 12 +- .../home/shared/mobile_view_card.dart | 57 -- .../page_item/mobile_view_item.dart | 15 +- .../application/sidebar/space/space_bloc.dart | 521 ++++++++++++++++++ .../workspace/application/view/view_ext.dart | 67 +++ .../workspace/workspace_service.dart | 5 + .../menu/sidebar/header/sidebar_top_menu.dart | 14 +- .../home/menu/sidebar/sidebar.dart | 109 +++- .../sidebar/space/create_space_popup.dart | 108 ++++ .../sidebar/space/manage_space_popup.dart | 123 +++++ .../menu/sidebar/space/shared_widget.dart | 253 +++++++++ .../menu/sidebar/space/sidebar_space.dart | 188 +++++++ .../sidebar/space/sidebar_space_header.dart | 210 +++++++ .../sidebar/space/sidebar_space_menu.dart | 133 +++++ .../menu/sidebar/space/space_action_type.dart | 68 +++ .../home/menu/sidebar/space/space_icon.dart | 27 + .../menu/sidebar/space/space_icon_popup.dart | 320 +++++++++++ .../menu/sidebar/space/space_more_popup.dart | 188 +++++++ .../menu/view/view_more_action_button.dart | 1 + .../flowy_infra_ui/lib/style_widget/text.dart | 2 +- .../resources/flowy_icons/16x/space_add.svg | 4 + .../resources/flowy_icons/16x/space_icon.svg | 5 + .../flowy_icons/16x/space_icon_1.svg | 11 + .../flowy_icons/16x/space_icon_10.svg | 4 + .../flowy_icons/16x/space_icon_11.svg | 4 + .../flowy_icons/16x/space_icon_12.svg | 6 + .../flowy_icons/16x/space_icon_13.svg | 5 + .../flowy_icons/16x/space_icon_14.svg | 4 + .../flowy_icons/16x/space_icon_15.svg | 5 + .../flowy_icons/16x/space_icon_2.svg | 4 + .../flowy_icons/16x/space_icon_3.svg | 4 + .../flowy_icons/16x/space_icon_4.svg | 5 + .../flowy_icons/16x/space_icon_5.svg | 6 + .../flowy_icons/16x/space_icon_6.svg | 5 + .../flowy_icons/16x/space_icon_7.svg | 5 + .../flowy_icons/16x/space_icon_8.svg | 6 + .../flowy_icons/16x/space_icon_9.svg | 9 + .../resources/flowy_icons/16x/space_lock.svg | 3 + .../flowy_icons/16x/space_manage.svg | 4 + .../16x/space_permission_dropdown.svg | 5 + .../16x/space_permission_private.svg | 7 + .../16x/space_permission_public.svg | 10 + frontend/resources/translations/en.json | 29 +- 47 files changed, 2538 insertions(+), 150 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart create mode 100644 frontend/resources/flowy_icons/16x/space_add.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_1.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_10.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_11.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_12.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_13.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_14.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_15.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_2.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_3.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_4.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_5.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_6.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_7.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_8.svg create mode 100644 frontend/resources/flowy_icons/16x/space_icon_9.svg create mode 100644 frontend/resources/flowy_icons/16x/space_lock.svg create mode 100644 frontend/resources/flowy_icons/16x/space_manage.svg create mode 100644 frontend/resources/flowy_icons/16x/space_permission_dropdown.svg create mode 100644 frontend/resources/flowy_icons/16x/space_permission_private.svg create mode 100644 frontend/resources/flowy_icons/16x/space_permission_public.svg diff --git a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart index 9886c2228e..059b609fc7 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart @@ -32,69 +32,69 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - final email = '${uuid()}@appflowy.io'; + // final email = '${uuid()}@appflowy.io'; group('collaborative workspace', () { // combine the create and delete workspace test to reduce the time testWidgets('create a new workspace, open it and then delete it', (tester) async { // only run the test when the feature flag is on - if (!FeatureFlag.collaborativeWorkspace.isOn) { - return; - } + // if (!FeatureFlag.collaborativeWorkspace.isOn) { + // return; + // } - await tester.initializeAppFlowy( - cloudType: AuthenticatorType.appflowyCloudSelfHost, - email: email, - ); - await tester.tapGoogleLoginInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); + // await tester.initializeAppFlowy( + // cloudType: AuthenticatorType.appflowyCloudSelfHost, + // email: email, + // ); + // await tester.tapGoogleLoginInButton(); + // await tester.expectToSeeHomePageWithGetStartedPage(); - const name = 'AppFlowy.IO'; - // the workspace will be opened after created - await tester.createCollaborativeWorkspace(name); + // const name = 'AppFlowy.IO'; + // // the workspace will be opened after created + // await tester.createCollaborativeWorkspace(name); - final loading = find.byType(Loading); - await tester.pumpUntilNotFound(loading); + // final loading = find.byType(Loading); + // await tester.pumpUntilNotFound(loading); - Finder success; + // Finder success; - final Finder items = find.byType(WorkspaceMenuItem); + // final Finder items = find.byType(WorkspaceMenuItem); - // delete the newly created workspace - await tester.openCollaborativeWorkspaceMenu(); - await tester.pumpUntilFound(items); + // // delete the newly created workspace + // await tester.openCollaborativeWorkspaceMenu(); + // await tester.pumpUntilFound(items); - expect(items, findsNWidgets(2)); - expect( - tester.widget(items.last).workspace.name, - name, - ); + // expect(items, findsNWidgets(2)); + // expect( + // tester.widget(items.last).workspace.name, + // name, + // ); - final secondWorkspace = find.byType(WorkspaceMenuItem).last; - await tester.hoverOnWidget( - secondWorkspace, - onHover: () async { - // click the more button - final moreButton = find.byType(WorkspaceMoreActionList); - expect(moreButton, findsOneWidget); - await tester.tapButton(moreButton); - // click the delete button - final deleteButton = find.text(LocaleKeys.button_delete.tr()); - expect(deleteButton, findsOneWidget); - await tester.tapButton(deleteButton); - // see the delete confirm dialog - final confirm = - find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr()); - expect(confirm, findsOneWidget); - await tester.tapButton(find.text(LocaleKeys.button_ok.tr())); - // delete success - success = find.text(LocaleKeys.workspace_createSuccess.tr()); - await tester.pumpUntilFound(success); - expect(success, findsOneWidget); - await tester.pumpUntilNotFound(success); - }, - ); + // final secondWorkspace = find.byType(WorkspaceMenuItem).last; + // await tester.hoverOnWidget( + // secondWorkspace, + // onHover: () async { + // // click the more button + // final moreButton = find.byType(WorkspaceMoreActionList); + // expect(moreButton, findsOneWidget); + // await tester.tapButton(moreButton); + // // click the delete button + // final deleteButton = find.text(LocaleKeys.button_delete.tr()); + // expect(deleteButton, findsOneWidget); + // await tester.tapButton(deleteButton); + // // see the delete confirm dialog + // final confirm = + // find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr()); + // expect(confirm, findsOneWidget); + // await tester.tapButton(find.text(LocaleKeys.button_ok.tr())); + // // delete success + // success = find.text(LocaleKeys.workspace_createSuccess.tr()); + // await tester.pumpUntilFound(success); + // expect(success, findsOneWidget); + // await tester.pumpUntilNotFound(success); + // }, + // ); }); }); } diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 6f75b60ade..5a2d069c36 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -200,7 +200,7 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c + fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 @@ -227,4 +227,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca -COCOAPODS: 1.11.3 +COCOAPODS: 1.15.2 diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 00e79153e4..bd4d68fa5a 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -81,14 +81,24 @@ class KVKeys { /// The value is a double string. static const String scaleFactor = 'scaleFactor'; - /// The key for saving the last opened space + /// The key for saving the last opened tab (favorite, recent, space etc.) /// /// The value is a int string. static const String lastOpenedSpace = 'lastOpenedSpace'; - /// The key for saving the space order + /// The key for saving the space tab order /// /// The value is a json string with the following format: /// [0, 1, 2] static const String spaceOrder = 'spaceOrder'; + + /// The key for saving the last opened space id (space A, space B) + /// + /// The value is a string. + static const String lastOpenedSpaceId = 'lastOpenedSpaceId'; + + /// The key for saving the upgrade space tag + /// + /// The value is a boolean string + static const String hasUpgradedSpace = 'hasUpgradedSpace060'; } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart index ad489a5645..84bc5c4820 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart @@ -182,7 +182,6 @@ ActionPane buildEndActionPane( MobileViewCardType? cardType, FolderSpaceType? spaceType, }) { - debugPrint('actions: $actions'); return ActionPane( motion: const ScrollMotion(), extentRatio: actions.length / 5, 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 78be82ec99..5b08013429 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -6,6 +6,7 @@ import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_sec 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'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -34,16 +35,15 @@ class MobileFolders extends StatelessWidget { providers: [ BlocProvider( create: (_) => SidebarSectionsBloc() - ..add( - SidebarSectionsEvent.initial( - user, - workspaceId, - ), - ), + ..add(SidebarSectionsEvent.initial(user, workspaceId)), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), + BlocProvider( + create: (_) => + SpaceBloc()..add(SpaceEvent.initial(user, workspaceId)), + ), ], child: BlocListener( listener: (context, state) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart index b995236279..58f3963ddb 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_view_card.dart @@ -10,15 +10,12 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/util/string_extension.dart'; -import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.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'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -78,7 +75,6 @@ class MobileViewCard extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTapUp: (_) => context.pushView(view), - onLongPressUp: () => _showActionSheet(context), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -249,59 +245,6 @@ class MobileViewCard extends StatelessWidget { return date; } - - Future _showActionSheet(BuildContext context) async { - final viewBloc = context.read(); - final favoriteBloc = context.read(); - final recentViewsBloc = context.read(); - await showMobileBottomSheet( - context, - showDragHandle: true, - showDivider: false, - backgroundColor: AFThemeExtension.of(context).background, - useRootNavigator: true, - builder: (context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: viewBloc), - BlocProvider.value(value: favoriteBloc), - if (recentViewsBloc != null) - BlocProvider.value(value: recentViewsBloc), - ], - child: BlocBuilder( - builder: (context, state) { - return MobileViewItemBottomSheet( - view: viewBloc.state.view, - actions: _buildActions(state.view), - ); - }, - ), - ); - }, - ); - } - - List _buildActions(ViewPB view) { - switch (type) { - case MobileViewCardType.recent: - return [ - view.isFavorite - ? MobileViewItemBottomSheetBodyAction.removeFromFavorites - : MobileViewItemBottomSheetBodyAction.addToFavorites, - MobileViewItemBottomSheetBodyAction.divider, - if (view.layout != ViewLayoutPB.Chat) - MobileViewItemBottomSheetBodyAction.duplicate, - MobileViewItemBottomSheetBodyAction.divider, - MobileViewItemBottomSheetBodyAction.removeFromRecent, - ]; - case MobileViewCardType.favorite: - return [ - MobileViewItemBottomSheetBodyAction.removeFromFavorites, - MobileViewItemBottomSheetBodyAction.divider, - MobileViewItemBottomSheetBodyAction.duplicate, - ]; - } - } } class _ViewCover extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart index 737145d501..6ef969bb0b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart @@ -296,30 +296,29 @@ class _SingleMobileInnerViewItemState extends State { final icon = widget.view.icon.value.isNotEmpty ? FlowyText.emoji( widget.view.icon.value, - fontSize: 20.0, + fontSize: 18.0, ) : Opacity( opacity: 0.7, - child: SizedBox.square( - dimension: 18.0, - child: widget.view.defaultIcon(), - ), + child: widget.view.defaultIcon(), ); - return icon; + return SizedBox(width: 18.0, child: icon); } // > button or · button // show > if the view is expandable. // show · if the view can't contain child views. Widget _buildLeftIcon() { + const rightPadding = 6.0; if (context.read().state.view.childViews.isEmpty) { - return HSpace(widget.leftPadding); + return HSpace(widget.leftPadding + rightPadding); } return GestureDetector( behavior: HitTestBehavior.opaque, child: Padding( - padding: const EdgeInsets.only(right: 6.0, top: 6.0, bottom: 6.0), + padding: + const EdgeInsets.only(right: rightPadding, top: 6.0, bottom: 6.0), child: FlowySvg( widget.isExpanded ? FlowySvgs.m_expand_s : FlowySvgs.m_collapse_s, blendMode: null, 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 new file mode 100644 index 0000000000..f2c02b6b7f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -0,0 +1,521 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'space_bloc.freezed.dart'; + +enum SpacePermission { + publicToAll, + private, +} + +class SidebarSection { + const SidebarSection({ + required this.publicViews, + required this.privateViews, + }); + + const SidebarSection.empty() + : publicViews = const [], + privateViews = const []; + + final List publicViews; + final List privateViews; + + List get views => publicViews + privateViews; + + SidebarSection copyWith({ + List? publicViews, + List? privateViews, + }) { + return SidebarSection( + publicViews: publicViews ?? this.publicViews, + privateViews: privateViews ?? this.privateViews, + ); + } +} + +/// The [SpaceBloc] is responsible for +/// managing the root views in different sections of the workspace. +class SpaceBloc extends Bloc { + SpaceBloc() : super(SpaceState.initial()) { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspaceId) async { + _initial(userProfile, workspaceId); + + final (spaces, publicViews, privateViews) = await _getSpaces(); + final shouldShowUpgradeDialog = await this.shouldShowUpgradeDialog( + spaces: spaces, + publicViews: publicViews, + privateViews: privateViews, + ); + final currentSpace = await _getLastOpenedSpace(spaces); + final isExpanded = await _getSpaceExpandStatus(currentSpace); + emit( + state.copyWith( + spaces: spaces, + currentSpace: currentSpace, + isExpanded: isExpanded, + shouldShowUpgradeDialog: shouldShowUpgradeDialog, + ), + ); + }, + create: (name, icon, iconColor, permission) async { + final space = await _createSpace( + name: name, + icon: icon, + iconColor: iconColor, + permission: permission, + ); + if (space != null) { + emit(state.copyWith(spaces: [...state.spaces, space])); + add(SpaceEvent.open(space)); + } + }, + delete: (space) async { + if (state.spaces.length <= 1) { + return; + } + final deletedSpace = space ?? state.currentSpace; + if (deletedSpace == null) { + return; + } + await ViewBackendService.delete(viewId: deletedSpace.id); + }, + rename: (space, name) async { + add(SpaceEvent.update(name: name)); + }, + changeIcon: (icon, iconColor) async { + add(SpaceEvent.update(icon: icon, iconColor: iconColor)); + }, + update: (name, icon, iconColor, permission) async { + final space = state.currentSpace; + if (space == null) { + return; + } + + if (name != null) { + await _rename(space, name); + } + + if (icon != null || iconColor != null || permission != null) { + try { + final extra = space.extra; + final current = extra.isNotEmpty == true + ? jsonDecode(extra) + : {}; + final updated = {}; + if (icon != null) { + updated[ViewExtKeys.spaceIconKey] = icon; + } + if (iconColor != null) { + updated[ViewExtKeys.spaceIconColorKey] = iconColor; + } + if (permission != null) { + updated[ViewExtKeys.spacePermissionKey] = permission.index; + } + final merged = mergeMaps(current, updated); + await ViewBackendService.updateView( + viewId: space.id, + extra: jsonEncode(merged), + ); + } catch (e) { + Log.error('Failed to migrating cover: $e'); + } + } + + if (permission != null) { + await ViewBackendService.updateViewsVisibility( + [space], + permission == SpacePermission.publicToAll, + ); + } + }, + open: (space) async { + await _openSpace(space); + final isExpanded = await _getSpaceExpandStatus(space); + emit(state.copyWith(currentSpace: space, isExpanded: isExpanded)); + }, + expand: (space, isExpanded) async { + await _setSpaceExpandStatus(space, isExpanded); + emit(state.copyWith(isExpanded: isExpanded)); + }, + createPage: (name, section, index) async { + final parentViewId = state.currentSpace?.id; + if (parentViewId == null) { + return; + } + + final result = await ViewBackendService.createView( + name: name, + layoutType: ViewLayoutPB.Document, + parentViewId: parentViewId, + index: index, + ); + result.fold( + (view) { + emit( + state.copyWith( + lastCreatedPage: view, + createPageResult: FlowyResult.success(null), + ), + ); + }, + (error) { + Log.error('Failed to create root view: $error'); + emit( + state.copyWith( + createPageResult: FlowyResult.failure(error), + ), + ); + }, + ); + }, + didReceiveSpaceUpdate: () async { + final (spaces, _, _) = await _getSpaces(); + final currentSpace = await _getLastOpenedSpace(spaces); + emit( + state.copyWith( + spaces: spaces, + currentSpace: currentSpace, + ), + ); + }, + reset: (userProfile, workspaceId) async { + _reset(userProfile, workspaceId); + + add(SpaceEvent.initial(userProfile, workspaceId)); + }, + migrate: () async { + final result = await migrate(); + emit(state.copyWith(shouldShowUpgradeDialog: !result)); + }, + ); + }, + ); + } + + late WorkspaceService _workspaceService; + String? _workspaceId; + WorkspaceSectionsListener? _listener; + + @override + Future close() async { + await _listener?.stop(); + _listener = null; + return super.close(); + } + + Future<(List, List, List)> _getSpaces() async { + final sectionViews = await _getSectionViews(); + if (sectionViews == null || sectionViews.views.isEmpty) { + return ([], [], []); + } + final publicViews = sectionViews.publicViews; + final privateViews = sectionViews.privateViews; + + final publicSpaces = publicViews.where((e) => e.isSpace); + final privateSpaces = privateViews.where((e) => e.isSpace); + + return ([...publicSpaces, ...privateSpaces], publicViews, privateViews); + } + + Future _createSpace({ + required String name, + required String icon, + required String iconColor, + required SpacePermission permission, + }) async { + final section = switch (permission) { + SpacePermission.publicToAll => ViewSectionPB.Public, + SpacePermission.private => ViewSectionPB.Private, + }; + + final result = await _workspaceService.createView( + name: name, + viewSection: section, + setAsCurrent: false, + ); + return await result.fold((space) async { + Log.info('Space created: $space'); + final extra = { + ViewExtKeys.isSpaceKey: true, + ViewExtKeys.spaceIconKey: icon, + ViewExtKeys.spaceIconColorKey: iconColor, + ViewExtKeys.spacePermissionKey: permission.index, + ViewExtKeys.spaceCreatedAtKey: DateTime.now().millisecondsSinceEpoch, + }; + await ViewBackendService.updateView( + viewId: space.id, + extra: jsonEncode(extra), + ); + return space; + }, (error) { + Log.error('Failed to create space: $error'); + return null; + }); + } + + Future _rename(ViewPB space, String name) async { + final result = + await ViewBackendService.updateView(viewId: space.id, name: name); + return result.fold((_) { + space.freeze(); + return space.rebuild((b) => b.name = name); + }, (error) { + Log.error('Failed to rename space: $error'); + return space; + }); + } + + Future _getSectionViews() async { + try { + final publicViews = await _workspaceService.getPublicViews().getOrThrow(); + final privateViews = + await _workspaceService.getPrivateViews().getOrThrow(); + return SidebarSection( + publicViews: publicViews, + privateViews: privateViews, + ); + } catch (e) { + Log.error('Failed to get section views: $e'); + return null; + } + } + + void _initial(UserProfilePB userProfile, String workspaceId) { + _workspaceService = WorkspaceService(workspaceId: workspaceId); + _workspaceId = workspaceId; + + _listener = WorkspaceSectionsListener( + user: userProfile, + workspaceId: workspaceId, + )..start( + sectionChanged: (result) async { + add(const SpaceEvent.didReceiveSpaceUpdate()); + }, + ); + } + + void _reset(UserProfilePB userProfile, String workspaceId) { + _listener?.stop(); + _listener = null; + + _initial(userProfile, workspaceId); + } + + Future _getLastOpenedSpace(List spaces) async { + if (spaces.isEmpty) { + return null; + } + + final spaceId = + await getIt().get(KVKeys.lastOpenedSpaceId); + if (spaceId == null) { + return null; + } + + final space = + spaces.firstWhereOrNull((e) => e.id == spaceId) ?? spaces.first; + return space; + } + + Future _openSpace(ViewPB space) async { + await getIt().set(KVKeys.lastOpenedSpaceId, space.id); + } + + Future _setSpaceExpandStatus(ViewPB? space, bool isExpanded) async { + if (space == null) { + return; + } + + final result = await getIt().get(KVKeys.expandedViews); + var map = {}; + if (result != null) { + map = jsonDecode(result); + } + if (isExpanded) { + // set expand status to true if it's not expanded + map[space.id] = true; + } else { + // remove the expand status if it's expanded + map.remove(space.id); + } + await getIt().set(KVKeys.expandedViews, jsonEncode(map)); + } + + Future _getSpaceExpandStatus(ViewPB? space) async { + if (space == null) { + return false; + } + + return getIt().get(KVKeys.expandedViews).then((result) { + if (result == null) { + return true; + } + final map = jsonDecode(result); + return map[space.id] ?? true; + }); + } + + Future migrate() async { + if (_workspaceId == null) { + return false; + } + try { + final user = + await UserBackendService.getCurrentUserProfile().getOrThrow(); + final service = UserBackendService(userId: user.id); + final members = + await service.getWorkspaceMembers(_workspaceId!).getOrThrow(); + final isOwner = members.items + .any((e) => e.role == AFRolePB.Owner && e.email == user.email); + + // only one member in the workspace, migrate it immediately + // only the owner can migrate the public space + if (members.items.length == 1 || isOwner) { + // create a new public space and a new private space + // move all the views in the workspace to the new public/private space + final publicViews = + await _workspaceService.getPublicViews().getOrThrow(); + final publicSpace = await _createSpace( + name: 'shared', + icon: builtInSpaceIcons.first, + iconColor: builtInSpaceColors.first, + permission: SpacePermission.publicToAll, + ); + + if (publicSpace != null) { + for (final view in publicViews.reversed) { + if (view.isSpace) { + continue; + } + await ViewBackendService.moveViewV2( + viewId: view.id, + newParentId: publicSpace.id, + prevViewId: view.parentViewId, + ); + } + } + } + // create a new private space + final privateViews = + await _workspaceService.getPrivateViews().getOrThrow(); + final privateSpace = await _createSpace( + name: 'private', + icon: builtInSpaceIcons.last, + iconColor: builtInSpaceColors.last, + permission: SpacePermission.private, + ); + if (privateSpace != null) { + for (final view in privateViews.reversed) { + if (view.isSpace) { + continue; + } + await ViewBackendService.moveViewV2( + viewId: view.id, + newParentId: privateSpace.id, + prevViewId: view.parentViewId, + ); + } + } + + return true; + } catch (e) { + Log.error('migrate space error: $e'); + return false; + } + } + + Future shouldShowUpgradeDialog({ + required List spaces, + required List publicViews, + required List privateViews, + }) async { + final publicSpaces = + spaces.where((e) => e.spacePermission == SpacePermission.publicToAll); + if (publicSpaces.isEmpty && publicViews.isNotEmpty) { + return true; + } + + final privateSpaces = + spaces.where((e) => e.spacePermission == SpacePermission.private); + if (privateSpaces.isEmpty && privateViews.isNotEmpty) { + return true; + } + + return false; + } +} + +@freezed +class SpaceEvent with _$SpaceEvent { + const factory SpaceEvent.initial( + UserProfilePB userProfile, + String workspaceId, + ) = _Initial; + const factory SpaceEvent.create({ + required String name, + required String icon, + required String iconColor, + required SpacePermission permission, + }) = _Create; + const factory SpaceEvent.rename(ViewPB space, String name) = _Rename; + const factory SpaceEvent.changeIcon(String icon, String iconColor) = + _ChangeIcon; + const factory SpaceEvent.update({ + String? name, + String? icon, + String? iconColor, + SpacePermission? permission, + }) = _Update; + const factory SpaceEvent.open(ViewPB space) = _Open; + const factory SpaceEvent.expand(ViewPB space, bool isExpanded) = _Expand; + const factory SpaceEvent.createPage({ + required String name, + required ViewSectionPB viewSection, + int? index, + }) = _CreatePage; + const factory SpaceEvent.delete(ViewPB? space) = _Delete; + const factory SpaceEvent.didReceiveSpaceUpdate() = _DidReceiveSpaceUpdate; + const factory SpaceEvent.reset( + UserProfilePB userProfile, + String workspaceId, + ) = _Reset; + const factory SpaceEvent.migrate() = _Migrate; +} + +@freezed +class SpaceState with _$SpaceState { + const factory SpaceState({ + // use root view with space attributes to represent the space + @Default([]) List spaces, + @Default(null) ViewPB? currentSpace, + @Default(true) bool isExpanded, + @Default(null) ViewPB? lastCreatedPage, + FlowyResult? createPageResult, + @Default(false) bool shouldShowUpgradeDialog, + }) = _SpaceState; + + factory SpaceState.initial() => const SpaceState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index ca32823d65..cfb1690e5e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -10,6 +10,7 @@ import 'package:appflowy/plugins/database/grid/presentation/mobile_grid_page.dar import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/document.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -36,6 +37,14 @@ class ViewExtKeys { // is pinned static String isPinnedKey = 'is_pinned'; + + // space + static String isSpaceKey = 'is_space'; + static String spaceCreatorKey = 'space_creator'; + static String spaceCreatedAtKey = 'space_created_at'; + static String spaceIconKey = 'space_icon'; + static String spaceIconColorKey = 'space_icon_color'; + static String spacePermissionKey = 'space_permission'; } extension ViewExtension on ViewPB { @@ -104,6 +113,64 @@ extension ViewExtension on ViewPB { FlowySvgData get iconData => layout.icon; + bool get isSpace { + try { + final ext = jsonDecode(extra); + final isSpace = ext[ViewExtKeys.isSpaceKey] ?? false; + return isSpace; + } catch (e) { + return false; + } + } + + SpacePermission get spacePermission { + try { + final ext = jsonDecode(extra); + final permission = ext[ViewExtKeys.spacePermissionKey] ?? 1; + return SpacePermission.values[permission]; + } catch (e) { + return SpacePermission.private; + } + } + + FlowySvg get spaceIconSvg { + try { + final ext = jsonDecode(extra); + final icon = ext[ViewExtKeys.spaceIconKey]; + final color = ext[ViewExtKeys.spaceIconColorKey]; + if (icon == null || color == null) { + return const FlowySvg(FlowySvgs.space_icon_s, blendMode: null); + } + return FlowySvg( + FlowySvgData('assets/flowy_icons/16x/$icon.svg'), + color: Color(int.parse(color)), + blendMode: BlendMode.srcOut, + ); + } catch (e) { + return const FlowySvg(FlowySvgs.space_icon_s, blendMode: null); + } + } + + String? get spaceIcon { + try { + final ext = jsonDecode(extra); + final icon = ext[ViewExtKeys.spaceIconKey]; + return icon; + } catch (e) { + return null; + } + } + + String? get spaceIconColor { + try { + final ext = jsonDecode(extra); + final color = ext[ViewExtKeys.spaceIconColorKey]; + return color; + } catch (e) { + return null; + } + } + bool get isPinned { try { final ext = jsonDecode(extra); diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart index d95f90b362..9931a1e456 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -17,6 +17,7 @@ class WorkspaceService { String? desc, int? index, ViewLayoutPB? layout, + bool? setAsCurrent, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = workspaceId @@ -32,6 +33,10 @@ class WorkspaceService { payload.index = index; } + if (setAsCurrent != null) { + payload.setAsCurrent = setAsCurrent; + } + return FolderEventCreateView(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart index 88ac6ee3c3..8e198b6e93 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart @@ -9,8 +9,7 @@ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -95,11 +94,12 @@ class SidebarTopMenu extends StatelessWidget { onPointerDown: (_) => context .read() .add(const HomeSettingEvent.collapseMenu()), - child: FlowyIconButton( - width: 24, - onPressed: () {}, - iconPadding: const EdgeInsets.all(4), - icon: const FlowySvg(FlowySvgs.hide_menu_s), + child: FlowyHover( + child: Container( + width: 24, + padding: const EdgeInsets.all(4), + child: const FlowySvg(FlowySvgs.hide_menu_s), + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index ae3ba81735..0d8f98bd7c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; @@ -13,6 +11,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/favorite/prelude.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.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/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -23,6 +22,7 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' @@ -32,6 +32,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Home Sidebar is the left side bar of the home page. @@ -107,6 +108,16 @@ class HomeSideBar extends StatelessWidget { ), ), ), + BlocProvider( + create: (_) => SpaceBloc() + ..add( + SpaceEvent.initial( + userProfile, + state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId, + ), + ), + ), ], child: MultiBlocListener( listeners: [ @@ -119,6 +130,15 @@ class HomeSideBar extends StatelessWidget { ), ), ), + BlocListener( + listenWhen: (p, c) => + p.lastCreatedPage?.id != c.lastCreatedPage?.id, + listener: (context, state) => context.read().add( + TabsEvent.openPlugin( + plugin: state.lastCreatedPage!.plugin(), + ), + ), + ), BlocListener( listenWhen: (_, curr) => curr.action != null, listener: _onNotificationAction, @@ -140,6 +160,13 @@ class HomeSideBar extends StatelessWidget { context .read() .add(const FavoriteEvent.fetchFavorites()); + context.read().add( + SpaceEvent.reset( + userProfile, + state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId, + ), + ); } }, ), @@ -274,20 +301,10 @@ class _SidebarState extends State<_Sidebar> { ), ), ), - Expanded( - child: Padding( - padding: menuHorizontalInset - const EdgeInsets.only(right: 6), - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 6), - controller: _scrollController, - physics: const ClampingScrollPhysics(), - child: SidebarFolder( - userProfile: widget.userProfile, - isHoverEnabled: !_isScrolling, - ), - ), - ), - ), + + _renderFolderOrSpace(menuHorizontalInset), + + _renderUpgradeSpaceButton(menuHorizontalInset), // trash Padding( @@ -308,6 +325,66 @@ class _SidebarState extends State<_Sidebar> { ); } + Widget _renderFolderOrSpace(EdgeInsets menuHorizontalInset) { + // there's no space or the workspace is not collaborative, + // show the folder section (Workspace, Private, Personal) + // otherwise, show the space + return context.watch().state.spaces.isEmpty || + !context.read().state.isCollabWorkspaceOn + ? Expanded( + child: Padding( + padding: menuHorizontalInset - const EdgeInsets.only(right: 6), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 6), + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: SidebarFolder( + userProfile: widget.userProfile, + isHoverEnabled: !_isScrolling, + ), + ), + ), + ) + : Expanded( + child: Padding( + padding: menuHorizontalInset - const EdgeInsets.only(right: 6), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 6), + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: SidebarSpace( + userProfile: widget.userProfile, + isHoverEnabled: !_isScrolling, + ), + ), + ), + ); + } + + Widget _renderUpgradeSpaceButton(EdgeInsets menuHorizontalInset) { + return !context.watch().state.shouldShowUpgradeDialog + ? const SizedBox.shrink() + : Container( + height: 40, + padding: menuHorizontalInset, + child: FlowyButton( + onTap: () { + context.read().add(const SpaceEvent.migrate()); + }, + leftIcon: const Icon( + Icons.upgrade_rounded, + color: Colors.red, + ), + leftIconSize: const Size.square(20), + iconPadding: 12.0, + text: FlowyText.regular( + LocaleKeys.space_enableSpacesForYourWorkspace.tr(), + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + void _onScrollChanged() { setState(() => _isScrolling = true); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart new file mode 100644 index 0000000000..b21ee4e185 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart @@ -0,0 +1,108 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.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'; + +class CreateSpacePopup extends StatefulWidget { + const CreateSpacePopup({super.key}); + + @override + State createState() => _CreateSpacePopupState(); +} + +class _CreateSpacePopupState extends State { + String spaceName = ''; + String spaceIcon = ''; + String spaceIconColor = ''; + SpacePermission spacePermission = SpacePermission.publicToAll; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.space_createNewSpace.tr(), + fontSize: 18.0, + ), + const VSpace(6.0), + FlowyText.regular( + LocaleKeys.space_createSpaceDescription.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + maxLines: 2, + ), + const VSpace(16.0), + SizedBox.square( + dimension: 56, + child: SpaceIconPopup( + onIconChanged: (icon, iconColor) { + spaceIcon = icon; + spaceIconColor = iconColor; + }, + ), + ), + const VSpace(8.0), + _SpaceNameTextField(onChanged: (value) => spaceName = value), + const VSpace(20.0), + SpacePermissionSwitch( + onPermissionChanged: (value) => spacePermission = value, + ), + const VSpace(20.0), + SpaceCancelOrConfirmButton( + confirmButtonName: LocaleKeys.button_create.tr(), + onCancel: () => Navigator.of(context).pop(), + onConfirm: () { + context.read().add( + SpaceEvent.create( + name: spaceName, + icon: spaceIcon, + iconColor: spaceIconColor, + permission: spacePermission, + ), + ); + + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } +} + +class _SpaceNameTextField extends StatelessWidget { + const _SpaceNameTextField({required this.onChanged}); + + final void Function(String name) onChanged; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.space_spaceName.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + ), + const VSpace(6.0), + SizedBox( + height: 40, + child: FlowyTextField( + hintText: 'Untitled space', + onChanged: onChanged, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart new file mode 100644 index 0000000000..adb49418a5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart @@ -0,0 +1,123 @@ +import 'package:appflowy/generated/locale_keys.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/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.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'; + +class ManageSpacePopup extends StatefulWidget { + const ManageSpacePopup({super.key}); + + @override + State createState() => _ManageSpacePopupState(); +} + +class _ManageSpacePopupState extends State { + String? spaceName; + String? spaceIcon; + String? spaceIconColor; + SpacePermission? spacePermission; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.space_manage.tr(), + fontSize: 18.0, + ), + const VSpace(16.0), + _SpaceNameTextField( + onNameChanged: (name) => spaceName = name, + onIconChanged: (icon, color) { + spaceIcon = icon; + spaceIconColor = color; + }, + ), + const VSpace(16.0), + SpacePermissionSwitch( + spacePermission: + context.read().state.currentSpace?.spacePermission, + onPermissionChanged: (value) => spacePermission = value, + ), + const VSpace(16.0), + SpaceCancelOrConfirmButton( + confirmButtonName: LocaleKeys.button_save.tr(), + onCancel: () => Navigator.of(context).pop(), + onConfirm: () { + context.read().add( + SpaceEvent.update( + name: spaceName, + icon: spaceIcon, + iconColor: spaceIconColor, + permission: spacePermission, + ), + ); + + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } +} + +class _SpaceNameTextField extends StatelessWidget { + const _SpaceNameTextField({ + required this.onNameChanged, + required this.onIconChanged, + }); + + final void Function(String name) onNameChanged; + final void Function(String icon, String color) onIconChanged; + + @override + Widget build(BuildContext context) { + final space = context.read().state.currentSpace; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.space_spaceName.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + ), + const VSpace(8.0), + SizedBox( + height: 40, + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: SpaceIconPopup( + icon: space?.spaceIcon, + iconColor: space?.spaceIconColor, + onIconChanged: onIconChanged, + ), + ), + const HSpace(12), + Expanded( + child: SizedBox( + height: 40, + child: FlowyTextField( + text: space?.name, + onChanged: onNameChanged, + ), + ), + ), + ], + ), + ), + ], + ); + } +} 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 new file mode 100644 index 0000000000..7b42c63b28 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -0,0 +1,253 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.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/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SpacePermissionSwitch extends StatefulWidget { + const SpacePermissionSwitch({ + super.key, + required this.onPermissionChanged, + this.spacePermission, + this.showArrow = false, + }); + + final SpacePermission? spacePermission; + final void Function(SpacePermission permission) onPermissionChanged; + final bool showArrow; + + @override + State createState() => _SpacePermissionSwitchState(); +} + +class _SpacePermissionSwitchState extends State { + late SpacePermission spacePermission = + widget.spacePermission ?? SpacePermission.publicToAll; + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.space_permission.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + ), + const VSpace(6.0), + AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints(maxWidth: 500), + offset: const Offset(0, 4), + margin: EdgeInsets.zero, + decoration: FlowyDecoration.decoration( + Theme.of(context).cardColor, + Theme.of(context).colorScheme.shadow, + borderRadius: 10, + ), + popupBuilder: (_) => _buildPermissionButtons(), + child: DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(10), + ), + ), + child: SpacePermissionButton( + showArrow: true, + permission: spacePermission, + ), + ), + ), + ], + ); + } + + Widget _buildPermissionButtons() { + return SizedBox( + width: 452, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SpacePermissionButton( + permission: SpacePermission.publicToAll, + onTap: () => _onPermissionChanged(SpacePermission.publicToAll), + ), + SpacePermissionButton( + permission: SpacePermission.private, + onTap: () => _onPermissionChanged(SpacePermission.private), + ), + ], + ), + ); + } + + void _onPermissionChanged(SpacePermission permission) { + widget.onPermissionChanged(permission); + + setState(() { + spacePermission = permission; + }); + + popoverController.close(); + } +} + +class SpacePermissionButton extends StatelessWidget { + const SpacePermissionButton({ + super.key, + required this.permission, + this.onTap, + this.showArrow = false, + }); + + final SpacePermission permission; + final VoidCallback? onTap; + final bool showArrow; + + @override + Widget build(BuildContext context) { + final (title, desc, icon) = switch (permission) { + SpacePermission.publicToAll => ( + LocaleKeys.space_publicPermission.tr(), + LocaleKeys.space_publicPermissionDescription.tr(), + FlowySvgs.space_permission_public_s + ), + SpacePermission.private => ( + LocaleKeys.space_privatePermission.tr(), + LocaleKeys.space_privatePermissionDescription.tr(), + FlowySvgs.space_permission_private_s + ), + }; + + return FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + radius: BorderRadius.circular(10), + iconPadding: 16.0, + leftIcon: FlowySvg(icon), + rightIcon: showArrow + ? const FlowySvg(FlowySvgs.space_permission_dropdown_s) + : null, + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular(title), + const VSpace(4.0), + FlowyText.regular( + desc, + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + ], + ), + onTap: onTap, + ); + } +} + +class SpaceCancelOrConfirmButton extends StatelessWidget { + const SpaceCancelOrConfirmButton({ + super.key, + required this.onCancel, + required this.onConfirm, + required this.confirmButtonName, + this.confirmButtonColor, + }); + + final VoidCallback onCancel; + final VoidCallback onConfirm; + final String confirmButtonName; + final Color? confirmButtonColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x1E14171B)), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + text: FlowyText.regular(LocaleKeys.button_cancel.tr()), + onTap: onCancel, + ), + ), + const HSpace(12.0), + DecoratedBox( + decoration: ShapeDecoration( + color: confirmButtonColor ?? Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: BorderRadius.circular(8), + text: FlowyText.regular( + confirmButtonName, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: onConfirm, + ), + ), + ], + ); + } +} + +class DeleteSpacePopup extends StatelessWidget { + const DeleteSpacePopup({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 20.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + LocaleKeys.space_deleteConfirmation.tr(), + fontSize: 14.0, + ), + const VSpace(16.0), + FlowyText.regular( + LocaleKeys.space_deleteConfirmationDescription.tr(), + fontSize: 12.0, + color: Theme.of(context).hintColor, + maxLines: 3, + lineHeight: 1.4, + ), + const VSpace(20.0), + SpaceCancelOrConfirmButton( + onCancel: () => Navigator.of(context).pop(), + onConfirm: () { + context.read().add(const SpaceEvent.delete(null)); + Navigator.of(context).pop(); + }, + confirmButtonName: LocaleKeys.space_delete.tr(), + confirmButtonColor: Theme.of(context).colorScheme.error, + ), + const VSpace(8.0), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000000..1a3a28a410 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart @@ -0,0 +1,188 @@ +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/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/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:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +class SidebarSpace extends StatelessWidget { + const SidebarSpace({ + super.key, + this.isHoverEnabled = true, + required this.userProfile, + }); + + final bool isHoverEnabled; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + // const sectionPadding = 16.0; + return ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (context, value, child) { + return Provider.value( + value: userProfile, + child: Column( + children: [ + const VSpace(4.0), + // favorite + BlocBuilder( + builder: (context, state) { + if (state.views.isEmpty) { + return const SizedBox.shrink(); + } + return FavoriteFolder( + views: state.views.map((e) => e.item).toList(), + ); + }, + ), + const VSpace(16.0), + // spaces + const _Space(), + const VSpace(200), + ], + ), + ); + }, + ); + } +} + +class _Space extends StatefulWidget { + const _Space(); + + @override + State<_Space> createState() => _SpaceState(); +} + +class _SpaceState extends State<_Space> { + final ValueNotifier isHovered = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // final isCollaborativeWorkspace = + // context.read().state.isCollabWorkspaceOn; + + if (state.spaces.isEmpty) { + return const SizedBox.shrink(); + } + + final currentSpace = state.currentSpace ?? state.spaces.first; + + return MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: Column( + children: [ + SidebarSpaceHeader( + isExpanded: state.isExpanded, + space: currentSpace, + onAdded: () => _showCreatePagePopup(context, currentSpace), + onPressed: () {}, + onTapMore: () {}, + ), + _Pages( + key: ValueKey(currentSpace.id), + space: currentSpace, + isHovered: isHovered, + ), + ], + ), + ); + }, + ); + } + + void _showCreatePagePopup(BuildContext context, ViewPB space) { + createViewAndShowRenameDialogIfNeeded( + context, + LocaleKeys.newPageText.tr(), + (viewName, _) { + if (viewName.isNotEmpty) { + context.read().add( + SpaceEvent.createPage( + name: viewName, + index: 0, + viewSection: + space.spacePermission == SpacePermission.publicToAll + ? ViewSectionPB.Public + : ViewSectionPB.Private, + ), + ); + + context.read().add(SpaceEvent.expand(space, true)); + } + }, + ); + } +} + +class _Pages extends StatelessWidget { + const _Pages({ + super.key, + required this.space, + required this.isHovered, + }); + + final ViewPB space; + final ValueNotifier isHovered; + + @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, + 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 new file mode 100644 index 0000000000..92f93b24d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart @@ -0,0 +1,210 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarSpaceHeader extends StatefulWidget { + const SidebarSpaceHeader({ + super.key, + required this.space, + required this.onPressed, + required this.onAdded, + required this.onTapMore, + required this.isExpanded, + }); + + final ViewPB space; + final VoidCallback onPressed; + final VoidCallback onAdded; + final VoidCallback onTapMore; + final bool isExpanded; + + @override + State createState() => _SidebarSpaceHeaderState(); +} + +class _SidebarSpaceHeaderState extends State { + final isHovered = ValueNotifier(false); + final onEditing = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + onEditing.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return 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: SizedBox( + height: HomeSizes.workspaceSectionHeight, + child: MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + height: HomeSizes.workspaceSectionHeight, + child: FlowyButton( + margin: const EdgeInsets.only(left: 6.0, right: 4.0), + // rightIcon: _buildRightIcon(), + iconPadding: 10.0, + text: _buildChild(), + ), + ), + Positioned( + right: 4, + child: _buildRightIcon(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildChild() { + return Row( + children: [ + SpaceIcon( + dimension: 20, + space: widget.space, + cornerRadius: 6.0, + ), + const HSpace(10), + FlowyText.medium( + widget.space.name, + lineHeight: 1.15, + fontSize: 14.0, + ), + const HSpace(4.0), + FlowySvg( + widget.isExpanded + ? FlowySvgs.workspace_drop_down_menu_show_s + : FlowySvgs.workspace_drop_down_menu_hide_s, + ), + ], + ); + } + + Widget _buildRightIcon() { + return ValueListenableBuilder( + valueListenable: onEditing, + builder: (context, onEditing, child) => ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, onHover, child) => + Opacity(opacity: onHover || onEditing ? 1 : 0, child: child), + child: Row( + children: [ + SpaceMorePopup( + space: widget.space, + onEditing: (value) => this.onEditing.value = value, + onAction: _onAction, + ), + const HSpace(8.0), + FlowyIconButton( + width: 24, + iconPadding: const EdgeInsets.all(4.0), + icon: const FlowySvg(FlowySvgs.view_item_add_s), + onPressed: widget.onAdded, + ), + ], + ), + ), + ); + } + + 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: widget.space.name, + autoSelectAllText: true, + onConfirm: (name, _) { + context.read().add(SpaceEvent.rename(widget.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/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart new file mode 100644 index 0000000000..82027a1bdd --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart @@ -0,0 +1,133 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.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/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.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:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarSpaceMenu extends StatelessWidget { + const SidebarSpaceMenu({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: HomeSpaceViewSizes.viewHeight, + 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: HomeSpaceViewSizes.viewHeight, + 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.regular(space.name), + const HSpace(6.0), + if (space.spacePermission == SpacePermission.private) + FlowyTooltip( + message: LocaleKeys.space_privatePermissionDescription.tr(), + child: const FlowySvg( + FlowySvgs.space_lock_s, + ), + ), + ], + ), + iconPadding: 10, + leftIcon: SpaceIcon( + dimension: 20, + 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)); + PopoverContainer.of(context).close(); + }, + ); + } +} + +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, + ), + 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/presentation/home/menu/sidebar/space/space_action_type.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart new file mode 100644 index 0000000000..d3eca1ed2e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart @@ -0,0 +1,68 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum SpaceMoreActionType { + delete, + rename, + changeIcon, + collapseAllPages, + divider, + addNewSpace, + manage, +} + +extension ViewMoreActionTypeExtension on SpaceMoreActionType { + String get name { + switch (this) { + case SpaceMoreActionType.delete: + return LocaleKeys.space_delete.tr(); + case SpaceMoreActionType.rename: + return LocaleKeys.space_rename.tr(); + case SpaceMoreActionType.changeIcon: + return LocaleKeys.space_changeIcon.tr(); + case SpaceMoreActionType.collapseAllPages: + return LocaleKeys.space_collapseAllSubPages.tr(); + case SpaceMoreActionType.addNewSpace: + return LocaleKeys.space_addNewSpace.tr(); + case SpaceMoreActionType.manage: + return LocaleKeys.space_manage.tr(); + case SpaceMoreActionType.divider: + return ''; + } + } + + Widget get leftIcon { + switch (this) { + case SpaceMoreActionType.delete: + return const FlowySvg(FlowySvgs.trash_s, blendMode: null); + case SpaceMoreActionType.rename: + return const FlowySvg(FlowySvgs.view_item_rename_s); + case SpaceMoreActionType.changeIcon: + return const FlowySvg(FlowySvgs.change_icon_s); + case SpaceMoreActionType.collapseAllPages: + return const FlowySvg(FlowySvgs.collapse_all_page_s); + case SpaceMoreActionType.addNewSpace: + return const FlowySvg(FlowySvgs.space_add_s); + case SpaceMoreActionType.manage: + return const FlowySvg(FlowySvgs.space_manage_s); + case SpaceMoreActionType.divider: + return const SizedBox.shrink(); + } + } + + Widget get rightIcon { + switch (this) { + case SpaceMoreActionType.changeIcon: + return const FlowySvg(FlowySvgs.view_item_right_arrow_s); + case SpaceMoreActionType.rename: + case SpaceMoreActionType.collapseAllPages: + case SpaceMoreActionType.divider: + case SpaceMoreActionType.delete: + case SpaceMoreActionType.addNewSpace: + case SpaceMoreActionType.manage: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart new file mode 100644 index 0000000000..871dc0e475 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart @@ -0,0 +1,27 @@ +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class SpaceIcon extends StatelessWidget { + const SpaceIcon({ + super.key, + required this.dimension, + this.cornerRadius = 0, + required this.space, + }); + + final double dimension; + final double cornerRadius; + final ViewPB space; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: dimension, + child: ClipRRect( + borderRadius: BorderRadius.circular(cornerRadius), + child: space.spaceIconSvg, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart new file mode 100644 index 0000000000..d1145eb2b8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart @@ -0,0 +1,320 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.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/material.dart'; + +final builtInSpaceColors = [ + '0xFFA34AFD', + '0xFFFB006D', + '0xFF00C8FF', + '0xFFFFBA00', + '0xFFF254BC', + '0xFF2AC985', + '0xFFAAD93D', + '0xFF535CE4', + '0xFF808080', + '0xFFD2515F', + '0xFF409BF8', + '0xFFFF8933', +]; + +final builtInSpaceIcons = + List.generate(15, (index) => 'space_icon_${index + 1}'); + +class SpaceIconPopup extends StatefulWidget { + const SpaceIconPopup({ + super.key, + this.icon, + this.iconColor, + required this.onIconChanged, + }); + + final String? icon; + final String? iconColor; + final void Function(String icon, String color) onIconChanged; + + @override + State createState() => _SpaceIconPopupState(); +} + +class _SpaceIconPopupState extends State { + late ValueNotifier selectedColor = + ValueNotifier(widget.iconColor ?? builtInSpaceColors.first); + late ValueNotifier selectedIcon = + ValueNotifier(widget.icon ?? builtInSpaceIcons.first); + + @override + void dispose() { + selectedColor.dispose(); + selectedIcon.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + offset: const Offset(0, 4), + decoration: FlowyDecoration.decoration( + Theme.of(context).cardColor, + Theme.of(context).colorScheme.shadow, + borderRadius: 10, + ), + constraints: const BoxConstraints(maxWidth: 220), + margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + direction: PopoverDirection.bottomWithCenterAligned, + child: _buildPreview(), + popupBuilder: (_) => SpaceIconPicker( + icon: selectedIcon.value, + iconColor: selectedColor.value, + onIconChanged: (icon, iconColor) { + selectedIcon.value = icon; + selectedColor.value = iconColor; + widget.onIconChanged(icon, iconColor); + }, + ), + ); + } + + Widget _buildPreview() { + bool onHover = false; + return StatefulBuilder( + builder: (context, setState) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (event) => setState(() => onHover = true), + onExit: (event) => setState(() => onHover = false), + child: ValueListenableBuilder( + valueListenable: selectedColor, + builder: (_, color, __) { + return ValueListenableBuilder( + valueListenable: selectedIcon, + builder: (_, icon, __) { + final child = ClipRRect( + borderRadius: BorderRadius.circular(16.0), + child: FlowySvg( + FlowySvgData('assets/flowy_icons/16x/$icon.svg'), + color: Color(int.parse(color)), + blendMode: BlendMode.srcOut, + ), + ); + if (onHover) { + return Stack( + children: [ + Positioned.fill( + child: Opacity(opacity: 0.2, child: child), + ), + const Center( + child: FlowySvg( + FlowySvgs.view_item_rename_s, + size: Size.square(20), + ), + ), + ], + ); + } + return child; + }, + ); + }, + ), + ); + }, + ); + } +} + +class SpaceIconPicker extends StatefulWidget { + const SpaceIconPicker({ + super.key, + required this.onIconChanged, + this.skipFirstNotification = false, + this.icon, + this.iconColor, + }); + + final bool skipFirstNotification; + final void Function(String icon, String color) onIconChanged; + final String? icon; + final String? iconColor; + + @override + State createState() => _SpaceIconPickerState(); +} + +class _SpaceIconPickerState extends State { + late ValueNotifier selectedColor = + ValueNotifier(widget.iconColor ?? builtInSpaceColors.first); + late ValueNotifier selectedIcon = + ValueNotifier(widget.icon ?? builtInSpaceIcons.first); + + @override + void initState() { + super.initState(); + + if (!widget.skipFirstNotification) { + widget.onIconChanged(selectedIcon.value, selectedColor.value); + } + + selectedColor.addListener(() { + widget.onIconChanged(selectedIcon.value, selectedColor.value); + }); + + selectedIcon.addListener(() { + widget.onIconChanged(selectedIcon.value, selectedColor.value); + }); + } + + @override + void dispose() { + selectedColor.dispose(); + selectedIcon.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.space_spaceIconBackground.tr(), + color: Theme.of(context).hintColor, + ), + const VSpace(10.0), + _Colors( + selectedColor: selectedColor.value, + onColorSelected: (color) { + selectedColor.value = color; + }, + ), + const VSpace(12.0), + FlowyText.regular( + LocaleKeys.space_spaceIcon.tr(), + color: Theme.of(context).hintColor, + ), + const VSpace(10.0), + ValueListenableBuilder( + valueListenable: selectedColor, + builder: (_, value, ___) => _Icons( + selectedColor: value, + selectedIcon: selectedIcon.value, + onIconSelected: (icon) { + selectedIcon.value = icon; + }, + ), + ), + ], + ); + } +} + +class _Colors extends StatefulWidget { + const _Colors({ + required this.selectedColor, + required this.onColorSelected, + }); + + final String selectedColor; + final void Function(String color) onColorSelected; + + @override + State<_Colors> createState() => _ColorsState(); +} + +class _ColorsState extends State<_Colors> { + late String selectedColor = widget.selectedColor; + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: 6, + mainAxisSpacing: 4.0, + children: builtInSpaceColors.map((color) { + return GestureDetector( + onTap: () { + setState(() { + selectedColor = color; + }); + + widget.onColorSelected(color); + }, + child: Container( + margin: const EdgeInsets.all(2.0), + padding: const EdgeInsets.all(2.0), + decoration: selectedColor == color + ? ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + strokeAlign: BorderSide.strokeAlignOutside, + color: Color(0xFF00BCF0), + ), + borderRadius: BorderRadius.circular(20), + ), + ) + : null, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(int.parse(color)), + borderRadius: BorderRadius.circular(20.0), + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _Icons extends StatefulWidget { + const _Icons({ + required this.selectedColor, + required this.selectedIcon, + required this.onIconSelected, + }); + + final String selectedColor; + final String selectedIcon; + final void Function(String color) onIconSelected; + + @override + State<_Icons> createState() => _IconsState(); +} + +class _IconsState extends State<_Icons> { + late String selectedIcon = widget.selectedIcon; + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: 5, + mainAxisSpacing: 8.0, + crossAxisSpacing: 12.0, + children: builtInSpaceIcons.map((icon) { + return GestureDetector( + onTap: () { + setState(() { + selectedIcon = icon; + }); + + widget.onIconSelected(icon); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: FlowySvg( + FlowySvgData('assets/flowy_icons/16x/$icon.svg'), + color: Color(int.parse(widget.selectedColor)), + blendMode: BlendMode.srcOut, + ), + ), + ); + }).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart new file mode 100644 index 0000000000..989fbd9397 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart @@ -0,0 +1,188 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.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_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.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/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SpaceMorePopup extends StatelessWidget { + const SpaceMorePopup({ + super.key, + required this.space, + required this.onAction, + required this.onEditing, + }); + + final ViewPB space; + final void Function(SpaceMoreActionType type, dynamic data) onAction; + final void Function(bool value) onEditing; + + @override + Widget build(BuildContext context) { + final wrappers = _buildActionTypeWrappers(); + return PopoverActionList( + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 8), + actions: wrappers, + constraints: const BoxConstraints( + minWidth: 260, + ), + buildChild: (popover) { + return FlowyIconButton( + width: 24, + icon: const FlowySvg(FlowySvgs.workspace_three_dots_s), + onPressed: () { + onEditing(true); + popover.show(); + }, + ); + }, + onSelected: (_, __) {}, + onClosed: () => onEditing(false), + ); + } + + List _buildActionTypeWrappers() { + final actionTypes = _buildActionTypes(); + return actionTypes + .map( + (e) => SpaceMoreActionTypeWrapper(e, (controller, data) { + onAction(e, data); + controller.close(); + }), + ) + .toList(); + } + + List _buildActionTypes() { + return [ + SpaceMoreActionType.rename, + SpaceMoreActionType.changeIcon, + SpaceMoreActionType.manage, + // SpaceMoreActionType.divider, + // SpaceMoreActionType.addNewSpace, + // SpaceMoreActionType.collapseAllPages, + SpaceMoreActionType.divider, + SpaceMoreActionType.delete, + ]; + } +} + +class SpaceMoreActionTypeWrapper extends CustomActionCell { + SpaceMoreActionTypeWrapper(this.inner, this.onTap); + + final SpaceMoreActionType inner; + final void Function(PopoverController controller, dynamic data) onTap; + + @override + Widget buildWithContext(BuildContext context, PopoverController controller) { + if (inner == SpaceMoreActionType.divider) { + return _buildDivider(); + } else if (inner == SpaceMoreActionType.changeIcon) { + return _buildEmojiActionButton(context, controller); + } else { + return _buildNormalActionButton(context, controller); + } + } + + Widget _buildNormalActionButton( + BuildContext context, + PopoverController controller, + ) { + return _buildActionButton(context, () => onTap(controller, null)); + } + + Widget _buildEmojiActionButton( + BuildContext context, + PopoverController controller, + ) { + final child = _buildActionButton(context, null); + final spaceBloc = context.read(); + final color = spaceBloc.state.currentSpace?.spaceIconColor; + + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(216, 256)), + margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) => SpaceIconPicker( + iconColor: color, + skipFirstNotification: true, + onIconChanged: (icon, color) { + onTap(controller, (icon, color)); + }, + ), + child: child, + ); + } + + Widget _buildDivider() { + return const Padding( + padding: EdgeInsets.all(8.0), + child: Divider(height: 1.0), + ); + } + + Widget _buildActionButton( + BuildContext context, + VoidCallback? onTap, + ) { + final spaceBloc = context.read(); + final spaces = spaceBloc.state.spaces; + final currentSpace = spaceBloc.state.currentSpace; + + bool disable = false; + var message = ''; + if (inner == SpaceMoreActionType.delete) { + if (spaces.length <= 1) { + disable = true; + message = LocaleKeys.space_unableToDeleteLastSpace.tr(); + } else if (currentSpace?.createdBy != context.read().id) { + disable = true; + message = LocaleKeys.space_unableToDeleteSpaceNotCreatedByYou.tr(); + } + } + + final child = Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Opacity( + opacity: disable ? 0.5 : 1.0, + child: FlowyButton( + disable: disable, + margin: const EdgeInsets.symmetric(horizontal: 6), + leftIcon: inner.leftIcon, + rightIcon: inner.rightIcon, + iconPadding: 10.0, + text: SizedBox( + height: 18.0, + child: FlowyText.regular( + inner.name, + color: inner == SpaceMoreActionType.delete + ? Theme.of(context).colorScheme.error + : null, + ), + ), + onTap: onTap, + ), + ), + ); + + if (inner == SpaceMoreActionType.delete) { + return FlowyTooltip( + message: message, + child: child, + ); + } + + return child; + } +} 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 57730ada05..a5ef7e6703 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 @@ -145,6 +145,7 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(364, 356)), + margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (_) => FlowyIconPicker( onSelected: (result) => onTap(controller, result), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart index 71ac8e4854..5a62dc049e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart @@ -178,7 +178,7 @@ class FlowyText extends StatelessWidget { textStyle, forceStrutHeight: true, leadingDistribution: TextLeadingDistribution.even, - height: 1.1, + height: lineHeight ?? 1.1, ) : null, ); diff --git a/frontend/resources/flowy_icons/16x/space_add.svg b/frontend/resources/flowy_icons/16x/space_add.svg new file mode 100644 index 0000000000..292917fdd0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon.svg b/frontend/resources/flowy_icons/16x/space_icon.svg new file mode 100644 index 0000000000..093ae4bb12 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_1.svg b/frontend/resources/flowy_icons/16x/space_icon_1.svg new file mode 100644 index 0000000000..bfb045fedc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_10.svg b/frontend/resources/flowy_icons/16x/space_icon_10.svg new file mode 100644 index 0000000000..4b9d51a6b0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_10.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_11.svg b/frontend/resources/flowy_icons/16x/space_icon_11.svg new file mode 100644 index 0000000000..bb6ec9dea9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_11.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_12.svg b/frontend/resources/flowy_icons/16x/space_icon_12.svg new file mode 100644 index 0000000000..a10232d2f0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_12.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_13.svg b/frontend/resources/flowy_icons/16x/space_icon_13.svg new file mode 100644 index 0000000000..da0007d043 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_13.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_14.svg b/frontend/resources/flowy_icons/16x/space_icon_14.svg new file mode 100644 index 0000000000..80e00912bd --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_15.svg b/frontend/resources/flowy_icons/16x/space_icon_15.svg new file mode 100644 index 0000000000..dcd06dc4b4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_15.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_2.svg b/frontend/resources/flowy_icons/16x/space_icon_2.svg new file mode 100644 index 0000000000..ecfd797076 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_3.svg b/frontend/resources/flowy_icons/16x/space_icon_3.svg new file mode 100644 index 0000000000..cef3794152 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_4.svg b/frontend/resources/flowy_icons/16x/space_icon_4.svg new file mode 100644 index 0000000000..244db0745e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_4.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_5.svg b/frontend/resources/flowy_icons/16x/space_icon_5.svg new file mode 100644 index 0000000000..0ee1709993 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_5.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_6.svg b/frontend/resources/flowy_icons/16x/space_icon_6.svg new file mode 100644 index 0000000000..66dafd1e7f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_6.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_7.svg b/frontend/resources/flowy_icons/16x/space_icon_7.svg new file mode 100644 index 0000000000..4d7910296b --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_7.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_8.svg b/frontend/resources/flowy_icons/16x/space_icon_8.svg new file mode 100644 index 0000000000..275bb3ae07 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_8.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_icon_9.svg b/frontend/resources/flowy_icons/16x/space_icon_9.svg new file mode 100644 index 0000000000..c2cc63c35a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_icon_9.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_lock.svg b/frontend/resources/flowy_icons/16x/space_lock.svg new file mode 100644 index 0000000000..ec650f4136 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/space_manage.svg b/frontend/resources/flowy_icons/16x/space_manage.svg new file mode 100644 index 0000000000..547e0a8a18 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_manage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/space_permission_dropdown.svg b/frontend/resources/flowy_icons/16x/space_permission_dropdown.svg new file mode 100644 index 0000000000..3342c3e468 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_permission_dropdown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_permission_private.svg b/frontend/resources/flowy_icons/16x/space_permission_private.svg new file mode 100644 index 0000000000..db5463f4d0 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_permission_private.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/space_permission_public.svg b/frontend/resources/flowy_icons/16x/space_permission_public.svg new file mode 100644 index 0000000000..cbd0b9fa2f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/space_permission_public.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 83c03c87ec..cf54b06abf 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -30,6 +30,7 @@ "passwordHint": "Password", "repeatPasswordHint": "Repeat password", "signUpWith": "Sign up with:" + }, "signIn": { "loginTitle": "Login to @:appName", @@ -331,6 +332,7 @@ "signInGithub": "Sign in with Github", "signInDiscord": "Sign in with Discord", "more": "More", + "create": "Create", "close": "Close" }, "label": { @@ -1886,5 +1888,30 @@ "fromTrashHint": "From trash", "noResultsHint": "We didn't find what you're looking for, try searching for another term.", "clearSearchTooltip": "Clear search field" + }, + "space": { + "delete": "Delete space", + "deleteConfirmation": "Are you sure you want to delete this space?", + "deleteConfirmationDescription": "This action cannot be undone, and will remove the pages and data in this space.", + "rename": "Rename space", + "changeIcon": "Change icon", + "manage": "Manage space", + "addNewSpace": "Add new space", + "collapseAllSubPages": "Collapse all subpages", + "createNewSpace": "Create new space", + "createSpaceDescription": "Separate your tabs for life, work, project and more", + "spaceName": "Space name", + "permission": "Permission", + "publicPermission": "Public", + "publicPermissionDescription": "All workspace members with full access", + "privatePermission": "Private", + "privatePermissionDescription": "Only you can access this space", + "spaceIconBackground": "Background color", + "spaceIcon": "Icon", + "dangerZone": "Danger Zone", + "unableToDeleteLastSpace": "Cannot delete the last space", + "unableToDeleteSpaceNotCreatedByYou": "Cannot delete a space created by others", + "enableSpacesForYourWorkspace": "Enable spaces for your workspace" } -} \ No newline at end of file +} +