From 2d674060c64749414f4521a238c6c2e1cba705a6 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 13 Jun 2024 13:43:29 +0800 Subject: [PATCH 1/5] 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 +} + From dc12938ab65a94ca22ea63c8c3a8df6971b90c80 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 13 Jun 2024 14:14:18 +0800 Subject: [PATCH 2/5] feat: support switching space on mobile (#5527) --- .../presentation/home/mobile_folders.dart | 120 ++++++++++----- .../presentation/home/space/mobile_space.dart | 143 ++++++++++++++++++ .../home/space/mobile_space_header.dart | 132 ++++++++++++++++ .../home/space/mobile_space_menu.dart | 128 ++++++++++++++++ .../application/sidebar/space/space_bloc.dart | 2 +- frontend/resources/translations/en.json | 3 +- 6 files changed, 484 insertions(+), 44 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart index 5b08013429..b98999a35d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/home.dart'; import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; @@ -53,53 +54,88 @@ class MobileFolders extends StatelessWidget { state.currentWorkspace?.workspaceId ?? workspaceId, ), ); + context.read().add( + SpaceEvent.reset( + user, + state.currentWorkspace?.workspaceId ?? workspaceId, + ), + ); }, - child: BlocConsumer( - listenWhen: (p, c) => - p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, - listener: (context, state) { - final lastCreatedRootView = state.lastCreatedRootView; - if (lastCreatedRootView != null) { - context.pushView(lastCreatedRootView); - } - }, - builder: (context, state) { - final isCollaborativeWorkspace = - context.read().state.isCollabWorkspaceOn; - return SlidableAutoCloseBehavior( - child: Column( - children: [ - ...isCollaborativeWorkspace - ? [ - MobileSectionFolder( - title: LocaleKeys.sideBar_workspace.tr(), - spaceType: FolderSpaceType.public, - views: state.section.publicViews, - ), - const VSpace(8.0), - MobileSectionFolder( - title: LocaleKeys.sideBar_private.tr(), - spaceType: FolderSpaceType.private, - views: state.section.privateViews, - ), - ] - : [ - MobileSectionFolder( - title: LocaleKeys.sideBar_personal.tr(), - spaceType: FolderSpaceType.public, - views: state.section.publicViews, - ), - ], - const VSpace(4.0), - const _TrashButton(), - ], - ), - ); - }, + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedPage?.id != c.lastCreatedPage?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedPage; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedRootView; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + return SlidableAutoCloseBehavior( + child: Column( + children: [ + ..._buildSpaceOrSection(context, state), + const VSpace(4.0), + const _TrashButton(), + ], + ), + ); + }, + ), ), ), ); } + + List _buildSpaceOrSection( + BuildContext context, + SidebarSectionsState state, + ) { + if (context.watch().state.spaces.isNotEmpty) { + return [ + const MobileSpace(), + ]; + } + + if (context.read().state.isCollabWorkspaceOn) { + return [ + MobileSectionFolder( + title: LocaleKeys.sideBar_workspace.tr(), + spaceType: FolderSpaceType.public, + views: state.section.publicViews, + ), + const VSpace(8.0), + MobileSectionFolder( + title: LocaleKeys.sideBar_private.tr(), + spaceType: FolderSpaceType.private, + views: state.section.privateViews, + ), + ]; + } + + return [ + MobileSectionFolder( + title: LocaleKeys.sideBar_personal.tr(), + spaceType: FolderSpaceType.public, + views: state.section.publicViews, + ), + ]; + } } class _TrashButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart new file mode 100644 index 0000000000..fd3b45cbd4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -0,0 +1,143 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSpace extends StatefulWidget { + const MobileSpace({super.key}); + + @override + State createState() => _MobileSpaceState(); +} + +class _MobileSpaceState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty) { + return const SizedBox.shrink(); + } + + final currentSpace = state.currentSpace ?? state.spaces.first; + + return Column( + children: [ + MobileSpaceHeader( + isExpanded: state.isExpanded, + space: currentSpace, + onAdded: () { + context.read().add( + SpaceEvent.createPage( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + index: 0, + viewSection: currentSpace.spacePermission == + SpacePermission.publicToAll + ? ViewSectionPB.Public + : ViewSectionPB.Private, + ), + ); + context.read().add( + SpaceEvent.expand(currentSpace, true), + ); + }, + onPressed: () => _showSpaceMenu(context), + ), + _Pages( + key: ValueKey(currentSpace.id), + space: currentSpace, + ), + ], + ); + }, + ); + } + + void _showSpaceMenu(BuildContext context) { + showMobileBottomSheet( + context, + showDivider: false, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + showDoneButton: true, + title: LocaleKeys.space_title.tr(), + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: MobileSpaceMenu(), + ), + ); + }, + ); + } +} + +class _Pages extends StatelessWidget { + const _Pages({ + super.key, + required this.space, + }); + + final ViewPB space; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + ViewBloc(view: space)..add(const ViewEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final spaceType = space.spacePermission == SpacePermission.publicToAll + ? FolderSpaceType.public + : FolderSpaceType.private; + return Column( + children: state.view.childViews + .map( + (view) => MobileViewItem( + key: ValueKey( + '${space.id} ${view.id}', + ), + spaceType: spaceType, + isFirstChild: view.id == state.view.childViews.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + onSelected: context.pushView, + endActionPane: (context) { + final view = context.read().state.view; + return buildEndActionPane( + context, + [ + MobilePaneActionType.more, + if (view.layout == ViewLayoutPB.Document) + MobilePaneActionType.add, + ], + spaceType: spaceType, + needSpace: false, + ); + }, + ), + ) + .toList(), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart new file mode 100644 index 0000000000..ffc1691404 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +@visibleForTesting +const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey'); + +class MobileSpaceHeader extends StatelessWidget { + const MobileSpaceHeader({ + super.key, + required this.space, + required this.onPressed, + required this.onAdded, + required this.isExpanded, + }); + + final ViewPB space; + final VoidCallback onPressed; + final VoidCallback onAdded; + final bool isExpanded; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onPressed, + child: SizedBox( + height: 48, + child: Row( + children: [ + SpaceIcon( + dimension: 24, + space: space, + cornerRadius: 6.0, + ), + const HSpace(8), + FlowyText.medium( + space.name, + lineHeight: 1.15, + fontSize: 16.0, + ), + const HSpace(4.0), + const FlowySvg( + FlowySvgs.workspace_drop_down_menu_show_s, + ), + const Spacer(), + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onAdded, + child: const FlowySvg( + FlowySvgs.m_space_add_s, + ), + ), + ], + ), + ), + ); + } + + // Future _onAction(SpaceMoreActionType type, dynamic data) async { + // switch (type) { + // case SpaceMoreActionType.rename: + // await _showRenameDialog(); + // break; + // case SpaceMoreActionType.changeIcon: + // final (String icon, String iconColor) = data; + // context.read().add(SpaceEvent.changeIcon(icon, iconColor)); + // break; + // case SpaceMoreActionType.manage: + // _showManageSpaceDialog(context); + // break; + // case SpaceMoreActionType.addNewSpace: + // break; + // case SpaceMoreActionType.collapseAllPages: + // break; + // case SpaceMoreActionType.delete: + // _showDeleteSpaceDialog(context); + // break; + // case SpaceMoreActionType.divider: + // break; + // } + // } + + // Future _showRenameDialog() async { + // await NavigatorTextFieldDialog( + // title: LocaleKeys.space_rename.tr(), + // value: space.name, + // autoSelectAllText: true, + // onConfirm: (name, _) { + // context.read().add(SpaceEvent.rename(space, name)); + // }, + // ).show(context); + // } + + // void _showManageSpaceDialog(BuildContext context) { + // final spaceBloc = context.read(); + // showDialog( + // context: context, + // builder: (_) { + // return Dialog( + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(12.0), + // ), + // child: BlocProvider.value( + // value: spaceBloc, + // child: const ManageSpacePopup(), + // ), + // ); + // }, + // ); + // } + + // void _showDeleteSpaceDialog(BuildContext context) { + // final spaceBloc = context.read(); + // showDialog( + // context: context, + // builder: (_) { + // return Dialog( + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(12.0), + // ), + // child: BlocProvider.value( + // value: spaceBloc, + // child: const SizedBox(width: 440, child: DeleteSpacePopup()), + // ), + // ); + // }, + // ); + // } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart new file mode 100644 index 0000000000..6788e1396d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -0,0 +1,128 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSpaceMenu extends StatelessWidget { + const MobileSpaceMenu({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(4.0), + for (final space in state.spaces) + SizedBox( + height: 52, + child: _SidebarSpaceMenuItem( + space: space, + isSelected: state.currentSpace?.id == space.id, + ), + ), + // const Padding( + // padding: EdgeInsets.symmetric(vertical: 8.0), + // child: Divider( + // height: 0.5, + // ), + // ), + // const SizedBox( + // height: 52, + // child: _CreateSpaceButton(), + // ), + ], + ); + }, + ); + } +} + +class _SidebarSpaceMenuItem extends StatelessWidget { + const _SidebarSpaceMenuItem({ + required this.space, + required this.isSelected, + }); + + final ViewPB space; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return FlowyButton( + text: Row( + children: [ + FlowyText.medium( + space.name, + ), + const HSpace(6.0), + if (space.spacePermission == SpacePermission.private) + const FlowySvg( + FlowySvgs.space_lock_s, + size: Size.square(12), + ), + ], + ), + iconPadding: 10, + leftIcon: SpaceIcon( + dimension: 24, + space: space, + cornerRadius: 6.0, + ), + leftIconSize: const Size.square(20), + rightIcon: isSelected + ? const FlowySvg( + FlowySvgs.workspace_selected_s, + blendMode: null, + ) + : null, + onTap: () { + context.read().add(SpaceEvent.open(space)); + Navigator.of(context).pop(); + }, + ); + } +} + +// class _CreateSpaceButton extends StatelessWidget { +// const _CreateSpaceButton(); + +// @override +// Widget build(BuildContext context) { +// return FlowyButton( +// text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()), +// iconPadding: 10, +// leftIcon: const FlowySvg( +// FlowySvgs.space_add_s, +// ), +// leftIconSize: const Size.square(20), +// onTap: () { +// PopoverContainer.of(context).close(); +// _showCreateSpaceDialog(context); +// }, +// ); +// } + +// void _showCreateSpaceDialog(BuildContext context) { +// final spaceBloc = context.read(); +// showDialog( +// context: context, +// builder: (_) { +// return Dialog( +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(12.0), +// ), +// child: BlocProvider.value( +// value: spaceBloc, +// child: const CreateSpacePopup(), +// ), +// ); +// }, +// ); +// } +// } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index f2c02b6b7f..8d3e8c557d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -332,7 +332,7 @@ class SpaceBloc extends Bloc { final spaceId = await getIt().get(KVKeys.lastOpenedSpaceId); if (spaceId == null) { - return null; + return spaces.first; } final space = diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index cf54b06abf..4c00612fec 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1911,7 +1911,8 @@ "dangerZone": "Danger Zone", "unableToDeleteLastSpace": "Cannot delete the last space", "unableToDeleteSpaceNotCreatedByYou": "Cannot delete a space created by others", - "enableSpacesForYourWorkspace": "Enable spaces for your workspace" + "enableSpacesForYourWorkspace": "Enable spaces for your workspace", + "title": "Spaces" } } From 2d4300e9316375377fe91b2b12a4e4b214b3408b Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Thu, 13 Jun 2024 08:25:11 +0200 Subject: [PATCH 3/5] chore: update version to 0.6.0 (#5528) --- frontend/Makefile.toml | 2 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index a71ffec1fc..dbc74be7e4 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.5.9" +APPFLOWY_VERSION = "0.6.0" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index bbefbe3c05..96a193e7f7 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.5.9 +version: 0.6.0 environment: flutter: ">=3.22.0" From aa621a8d8433c7ca738392dc00a3dbc6ad94de1f Mon Sep 17 00:00:00 2001 From: Mohammad Zolfaghari Date: Thu, 13 Jun 2024 10:22:13 +0330 Subject: [PATCH 4/5] feat: timer field (#5349) * feat: wip timer field * feat: timer field fixing errors * feat: wip timer field frontend * fix: parsing TimerCellDataPB * feat: parse time string to minutes * fix: don't allow none number input * fix: timer filter * style: cargo fmt * fix: clippy errors * refactor: rename field type timer to time * refactor: missed some variable and files to rename * style: cargo fmt fix * feat: format time field type data in frontend * style: fix cargo fmt * fix: fixes after merge --------- Co-authored-by: Mathias Mogensen --- .../field/mobile_field_bottom_sheets.dart | 1 + .../field/mobile_full_field_editor.dart | 1 + .../application/cell/bloc/time_cell_bloc.dart | 117 +++++++++ .../cell/cell_controller_builder.dart | 14 +- .../application/cell/cell_data_loader.dart | 15 ++ .../application/field/field_info.dart | 2 + .../database/domain/filter_service.dart | 24 ++ .../filter/filter_create_bloc.dart | 5 + .../filter/time_filter_editor_bloc.dart | 111 +++++++++ .../widgets/filter/choicechip/time.dart | 227 ++++++++++++++++++ .../widgets/filter/filter_info.dart | 6 + .../widgets/filter/filter_menu_item.dart | 6 +- .../widgets/cell/card_cell_builder.dart | 16 +- .../card_cell_skeleton/time_card_cell.dart | 62 +++++ .../desktop_board_card_cell_style.dart | 8 +- .../mobile_board_card_cell_style.dart | 8 +- .../desktop_grid/desktop_grid_time_cell.dart | 37 +++ .../desktop_row_detail_time_cell.dart | 40 +++ .../widgets/cell/editable_cell_builder.dart | 18 +- .../cell/editable_cell_skeleton/time.dart | 120 +++++++++ .../mobile_grid/mobile_grid_time_cell.dart | 29 +++ .../mobile_row_detail_time_cell.dart | 46 ++++ .../widgets/field/field_type_list.dart | 4 +- .../field/type_option_editor/builder.dart | 5 +- .../field/type_option_editor/time.dart | 19 ++ .../lib/util/field_type_extension.dart | 4 + frontend/appflowy_flutter/lib/util/time.dart | 43 ++++ frontend/appflowy_flutter/pubspec.lock | 24 ++ .../test/unit_test/util/time.dart | 24 ++ frontend/resources/translations/en.json | 3 +- .../src/database_event.rs | 6 + .../src/entities/field_entities.rs | 6 + .../src/entities/filter_entities/mod.rs | 2 + .../entities/filter_entities/time_filter.rs | 23 ++ .../src/entities/filter_entities/util.rs | 9 +- .../flowy-database2/src/entities/macros.rs | 1 + .../src/entities/type_option_entities/mod.rs | 2 + .../type_option_entities/time_entities.rs | 28 +++ .../src/services/cell/cell_operation.rs | 2 +- .../src/services/field/type_options/mod.rs | 2 + .../text_type_option/text_type_option.rs | 7 +- .../type_options/time_type_option/mod.rs | 6 + .../type_options/time_type_option/time.rs | 115 +++++++++ .../time_type_option/time_entities.rs | 47 ++++ .../time_type_option/time_filter.rs | 72 ++++++ .../field/type_options/type_option.rs | 10 +- .../field/type_options/type_option_cell.rs | 19 +- .../src/services/filter/controller.rs | 4 + .../src/services/filter/entities.rs | 6 + .../tests/database/cell_test/test.rs | 19 +- .../tests/database/field_test/test.rs | 20 ++ .../tests/database/field_test/util.rs | 20 +- .../tests/database/filter_test/mod.rs | 1 + .../database/filter_test/time_filter_test.rs | 121 ++++++++++ .../database/mock_data/board_mock_data.rs | 6 + .../database/mock_data/grid_mock_data.rs | 10 +- .../tests/database/share_test/export_test.rs | 2 + 57 files changed, 1579 insertions(+), 26 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/time_filter_editor_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart create mode 100644 frontend/appflowy_flutter/lib/util/time.dart create mode 100644 frontend/appflowy_flutter/test/unit_test/util/time.dart create mode 100644 frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs create mode 100644 frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_entities.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs create mode 100644 frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart index 266a06de7f..7d81801d73 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart @@ -28,6 +28,7 @@ const mobileSupportedFieldTypes = [ FieldType.CreatedTime, FieldType.Checkbox, FieldType.Checklist, + FieldType.Time, ]; Future showFieldTypeGridBottomSheet( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart index 26c9d462a6..ef8ce6e51d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart @@ -119,6 +119,7 @@ class FieldOptionValues { case FieldType.RichText: case FieldType.URL: case FieldType.Checkbox: + case FieldType.Time: return null; case FieldType.Number: return NumberTypeOptionPB( diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart new file mode 100644 index 0000000000..62ff95850f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/util/time.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +part 'time_cell_bloc.freezed.dart'; + +class TimeCellBloc extends Bloc { + TimeCellBloc({ + required this.cellController, + }) : super(TimeCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final TimeCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellUpdate: (content) { + emit( + state.copyWith( + content: + content != null ? formatTime(content.time.toInt()) : "", + ), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateCell: (text) async { + text = parseTime(text)?.toString() ?? text; + if (state.content != text) { + emit(state.copyWith(content: text)); + await cellController.saveCellData(text); + + // If the input content is "abc" that can't parsered as number + // then the data stored in the backend will be an empty string. + // So for every cell data that will be formatted in the backend. + // It needs to get the formatted data after saving. + add( + TimeCellEvent.didReceiveCellUpdate( + cellController.getCellData(), + ), + ); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellContent) { + if (!isClosed) { + add(TimeCellEvent.didReceiveCellUpdate(cellContent)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(TimeCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class TimeCellEvent with _$TimeCellEvent { + const factory TimeCellEvent.didReceiveCellUpdate(TimeCellDataPB? cell) = + _DidReceiveCellUpdate; + const factory TimeCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory TimeCellEvent.updateCell(String text) = _UpdateCell; +} + +@freezed +class TimeCellState with _$TimeCellState { + const factory TimeCellState({ + required String content, + required bool wrap, + }) = _TimeCellState; + + factory TimeCellState.initial(TimeCellController cellController) { + final wrap = cellController.fieldInfo.wrapCellContent; + final cellData = cellController.getCellData(); + return TimeCellState( + content: cellData != null ? formatTime(cellData.time.toInt()) : "", + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart index e9457e23dc..50ef7ccb74 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart @@ -16,6 +16,7 @@ typedef TimestampCellController = CellController; typedef URLCellController = CellController; typedef RelationCellController = CellController; typedef SummaryCellController = CellController; +typedef TimeCellController = CellController; typedef TranslateCellController = CellController; CellController makeCellController( @@ -121,7 +122,6 @@ CellController makeCellController( ), cellDataPersistence: TextCellDataPersistence(), ); - case FieldType.Relation: return RelationCellController( viewId: viewId, @@ -146,6 +146,18 @@ CellController makeCellController( ), cellDataPersistence: TextCellDataPersistence(), ); + case FieldType.Time: + return TimeCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: TimeCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); case FieldType.Translate: return TranslateCellController( viewId: viewId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart index 4edae575ce..1c03239cde 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart @@ -181,3 +181,18 @@ class RelationCellDataParser implements CellDataParser { } } } + +class TimeCellDataParser implements CellDataParser { + @override + TimeCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + try { + return TimeCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse timer data: $e"); + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart index e071c6edae..bc5107f75c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -64,6 +64,7 @@ class FieldInfo with _$FieldInfo { case FieldType.SingleSelect: case FieldType.Checklist: case FieldType.URL: + case FieldType.Time: return true; default: return false; @@ -85,6 +86,7 @@ class FieldInfo with _$FieldInfo { case FieldType.LastEditedTime: case FieldType.CreatedTime: case FieldType.Checklist: + case FieldType.Time: return true; default: return false; diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart index 64854a8faf..e618da5de9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -202,6 +202,30 @@ class FilterBackendService { ); } + Future> insertTimeFilter({ + required String fieldId, + String? filterId, + required NumberFilterConditionPB condition, + String content = "", + }) { + final filter = TimeFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Time, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Time, + data: filter.writeToBuffer(), + ); + } + Future> insertFilter({ required String fieldId, required FieldType fieldType, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index d8ea5906a8..a27b0bf000 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart @@ -127,6 +127,11 @@ class GridCreateFilterBloc fieldId: fieldId, condition: NumberFilterConditionPB.Equal, ); + case FieldType.Time: + return _filterBackendSvc.insertTimeFilter( + fieldId: fieldId, + condition: NumberFilterConditionPB.Equal, + ); case FieldType.RichText: return _filterBackendSvc.insertTextFilter( fieldId: fieldId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/time_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/time_filter_editor_bloc.dart new file mode 100644 index 0000000000..65625ca7f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/time_filter_editor_bloc.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'time_filter_editor_bloc.freezed.dart'; + +class TimeFilterEditorBloc + extends Bloc { + TimeFilterEditorBloc({required this.filterInfo}) + : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), + _listener = FilterListener( + viewId: filterInfo.viewId, + filterId: filterInfo.filter.id, + ), + super(TimeFilterEditorState.initial(filterInfo)) { + _dispatch(); + _startListening(); + } + + final FilterInfo filterInfo; + final FilterBackendService _filterBackendSvc; + final FilterListener _listener; + + void _dispatch() { + on( + (event, emit) async { + event.when( + didReceiveFilter: (filter) { + final filterInfo = state.filterInfo.copyWith(filter: filter); + emit( + state.copyWith( + filterInfo: filterInfo, + filter: filterInfo.timeFilter()!, + ), + ); + }, + updateCondition: (NumberFilterConditionPB condition) { + _filterBackendSvc.insertTimeFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: condition, + content: state.filter.content, + ); + }, + updateContent: (content) { + _filterBackendSvc.insertTimeFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + content: content, + ); + }, + delete: () { + _filterBackendSvc.deleteFilter( + fieldId: filterInfo.fieldInfo.id, + filterId: filterInfo.filter.id, + ); + }, + ); + }, + ); + } + + void _startListening() { + _listener.start( + onUpdated: (filter) { + if (!isClosed) { + add(TimeFilterEditorEvent.didReceiveFilter(filter)); + } + }, + ); + } + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } +} + +@freezed +class TimeFilterEditorEvent with _$TimeFilterEditorEvent { + const factory TimeFilterEditorEvent.didReceiveFilter(FilterPB filter) = + _DidReceiveFilter; + const factory TimeFilterEditorEvent.updateCondition( + NumberFilterConditionPB condition, + ) = _UpdateCondition; + const factory TimeFilterEditorEvent.updateContent(String content) = + _UpdateContent; + const factory TimeFilterEditorEvent.delete() = _Delete; +} + +@freezed +class TimeFilterEditorState with _$TimeFilterEditorState { + const factory TimeFilterEditorState({ + required FilterInfo filterInfo, + required TimeFilterPB filter, + }) = _TimeFilterEditorState; + + factory TimeFilterEditorState.initial(FilterInfo filterInfo) { + return TimeFilterEditorState( + filterInfo: filterInfo, + filter: filterInfo.timeFilter()!, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart new file mode 100644 index 0000000000..828f124de1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart @@ -0,0 +1,227 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/time_filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; +import '../filter_info.dart'; +import 'choicechip.dart'; + +class TimeFilterChoiceChip extends StatefulWidget { + const TimeFilterChoiceChip({ + super.key, + required this.filterInfo, + }); + + final FilterInfo filterInfo; + + @override + State createState() => _TimeFilterChoiceChipState(); +} + +class _TimeFilterChoiceChipState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => TimeFilterEditorBloc( + filterInfo: widget.filterInfo, + ), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 100)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const TimeFilterEditor(), + ); + }, + child: ChoiceChipButton( + filterInfo: state.filterInfo, + ), + ); + }, + ), + ); + } +} + +class TimeFilterEditor extends StatefulWidget { + const TimeFilterEditor({super.key}); + + @override + State createState() => _TimeFilterEditorState(); +} + +class _TimeFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List children = [ + _buildFilterPanel(context, state), + if (state.filter.condition != NumberFilterConditionPB.NumberIsEmpty && + state.filter.condition != + NumberFilterConditionPB.NumberIsNotEmpty) ...[ + const VSpace(4), + _buildFilterTimeField(context, state), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel( + BuildContext context, + TimeFilterEditorState state, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + state.filterInfo.fieldInfo.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: TimeFilterConditionPBList( + filterInfo: state.filterInfo, + popoverMutex: popoverMutex, + onCondition: (condition) { + context + .read() + .add(TimeFilterEditorEvent.updateCondition(condition)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(const TimeFilterEditorEvent.delete()); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterTimeField( + BuildContext context, + TimeFilterEditorState state, + ) { + return FlowyTextField( + text: state.filter.content, + hintText: LocaleKeys.grid_settings_typeAValue.tr(), + debounceDuration: const Duration(milliseconds: 300), + autoFocus: false, + onChanged: (text) { + context + .read() + .add(TimeFilterEditorEvent.updateContent(text)); + }, + ); + } +} + +class TimeFilterConditionPBList extends StatelessWidget { + const TimeFilterConditionPBList({ + super.key, + required this.filterInfo, + required this.popoverMutex, + required this.onCondition, + }); + + final FilterInfo filterInfo; + final PopoverMutex popoverMutex; + final Function(NumberFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + final timeFilter = filterInfo.timeFilter()!; + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: NumberFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + timeFilter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: timeFilter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final NumberFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension TimeFilterConditionPBExtension on NumberFilterConditionPB { + String get filterName { + return switch (this) { + NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), + NumberFilterConditionPB.NotEqual => + LocaleKeys.grid_numberFilter_notEqual.tr(), + NumberFilterConditionPB.LessThan => + LocaleKeys.grid_numberFilter_lessThan.tr(), + NumberFilterConditionPB.LessThanOrEqualTo => + LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), + NumberFilterConditionPB.GreaterThan => + LocaleKeys.grid_numberFilter_greaterThan.tr(), + NumberFilterConditionPB.GreaterThanOrEqualTo => + LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), + NumberFilterConditionPB.NumberIsEmpty => + LocaleKeys.grid_numberFilter_isEmpty.tr(), + NumberFilterConditionPB.NumberIsNotEmpty => + LocaleKeys.grid_numberFilter_isNotEmpty.tr(), + _ => "", + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart index 19c201d026..0f355ebc4c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart @@ -60,4 +60,10 @@ class FilterInfo { ? NumberFilterPB.fromBuffer(filter.data.data) : null; } + + TimeFilterPB? timeFilter() { + return filter.data.fieldType == FieldType.Time + ? TimeFilterPB.fromBuffer(filter.data.data) + : null; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart index 3ca86d3969..7ce6b5a223 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart @@ -8,6 +8,7 @@ import 'choicechip/number.dart'; import 'choicechip/select_option/select_option.dart'; import 'choicechip/text.dart'; import 'choicechip/url.dart'; +import 'choicechip/time.dart'; import 'filter_info.dart'; class FilterMenuItem extends StatelessWidget { @@ -22,12 +23,15 @@ class FilterMenuItem extends StatelessWidget { FieldType.DateTime => DateFilterChoicechip(filterInfo: filterInfo), FieldType.MultiSelect => SelectOptionFilterChoicechip(filterInfo: filterInfo), - FieldType.Number => NumberFilterChoiceChip(filterInfo: filterInfo), + FieldType.Number => + NumberFilterChoiceChip(filterInfo: filterInfo), FieldType.RichText => TextFilterChoicechip(filterInfo: filterInfo), FieldType.SingleSelect => SelectOptionFilterChoicechip(filterInfo: filterInfo), FieldType.URL => URLFilterChoiceChip(filterInfo: filterInfo), FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo), + FieldType.Time => + TimeFilterChoiceChip(filterInfo: filterInfo), _ => const SizedBox(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart index 2b83b590d9..aff11f6584 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart @@ -1,19 +1,21 @@ +import 'package:flutter/widgets.dart'; + import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:flutter/widgets.dart'; import 'card_cell_skeleton/card_cell.dart'; import 'card_cell_skeleton/checkbox_card_cell.dart'; import 'card_cell_skeleton/checklist_card_cell.dart'; import 'card_cell_skeleton/date_card_cell.dart'; import 'card_cell_skeleton/number_card_cell.dart'; +import 'card_cell_skeleton/relation_card_cell.dart'; import 'card_cell_skeleton/select_option_card_cell.dart'; import 'card_cell_skeleton/summary_card_cell.dart'; import 'card_cell_skeleton/text_card_cell.dart'; +import 'card_cell_skeleton/time_card_cell.dart'; +import 'card_cell_skeleton/timestamp_card_cell.dart'; +import 'card_cell_skeleton/translate_card_cell.dart'; import 'card_cell_skeleton/url_card_cell.dart'; typedef CardCellStyleMap = Map; @@ -99,6 +101,12 @@ class CardCellBuilder { databaseController: databaseController, cellContext: cellContext, ), + FieldType.Time => TimeCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), FieldType.Translate => TranslateCardCell( key: key, style: isStyleOrNull(style), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart new file mode 100644 index 0000000000..68a95e53e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; + +import 'card_cell.dart'; + +class TimeCardCellStyle extends CardCellStyle { + const TimeCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class TimeCardCell extends CardCell { + const TimeCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _TimeCellState(); +} + +class _TimeCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return TimeCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text(state.content, style: widget.style.textStyle), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart index 333886c6f9..95b7baa494 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:flutter/material.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; @@ -10,6 +11,7 @@ import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; +import '../card_cell_skeleton/time_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; @@ -84,6 +86,10 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Time: TimeCardCellStyle( + padding: padding, + textStyle: textStyle, + ), FieldType.Translate: SummaryCardCellStyle( padding: padding, textStyle: textStyle, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart index 71a4d54b95..952d20e7e5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:flutter/material.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; @@ -10,6 +11,7 @@ import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; +import '../card_cell_skeleton/time_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; @@ -83,6 +85,10 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Time: TimeCardCellStyle( + padding: padding, + textStyle: textStyle, + ), FieldType.Translate: SummaryCardCellStyle( padding: padding, textStyle: textStyle, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart new file mode 100644 index 0000000000..a948e92b03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class DesktopGridTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: context.watch().state.wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart new file mode 100644 index 0000000000..ffd68933c9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class DesktopRowDetailTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart index 2ac68ed034..155a6003ce 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -1,9 +1,9 @@ -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../row/accessory/cell_accessory.dart'; @@ -18,6 +18,7 @@ import 'editable_cell_skeleton/relation.dart'; import 'editable_cell_skeleton/select_option.dart'; import 'editable_cell_skeleton/summary.dart'; import 'editable_cell_skeleton/text.dart'; +import 'editable_cell_skeleton/time.dart'; import 'editable_cell_skeleton/timestamp.dart'; import 'editable_cell_skeleton/url.dart'; @@ -121,6 +122,12 @@ class EditableCellBuilder { skin: IEditableSummaryCellSkin.fromStyle(style), key: key, ), + FieldType.Time => EditableTimeCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableTimeCellSkin.fromStyle(style), + key: key, + ), FieldType.Translate => EditableTranslateCell( databaseController: databaseController, cellContext: cellContext, @@ -213,6 +220,12 @@ class EditableCellBuilder { skin: skinMap.relationSkin!, key: key, ), + FieldType.Time => EditableTimeCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.timeSkin!, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -368,6 +381,7 @@ class EditableCellSkinMap { this.textSkin, this.urlSkin, this.relationSkin, + this.timeSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -379,6 +393,7 @@ class EditableCellSkinMap { final IEditableTextCellSkin? textSkin; final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; + final IEditableTimeCellSkin? timeSkin; bool has(FieldType fieldType) { return switch (fieldType) { @@ -394,6 +409,7 @@ class EditableCellSkinMap { FieldType.Number => numberSkin != null, FieldType.RichText => textSkin != null, FieldType.URL => urlSkin != null, + FieldType.Time => timeSkin != null, _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart new file mode 100644 index 0000000000..83c34bdf5d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_time_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_time_cell.dart'; +import '../mobile_grid/mobile_grid_time_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_time_cell.dart'; + +abstract class IEditableTimeCellSkin { + const IEditableTimeCellSkin(); + + factory IEditableTimeCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridTimeCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailTimeCellSkin(), + EditableCellStyle.mobileGrid => MobileGridTimeCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailTimeCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ); +} + +class EditableTimeCell extends EditableCellWidget { + EditableTimeCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableTimeCellSkin skin; + + @override + GridEditableTextCell createState() => _TimeCellState(); +} + +class _TimeCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = TimeCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listener: (context, state) => + _textEditingController.text = state.content, + child: Builder( + builder: (context) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + ); + }, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() { + focusNode.requestFocus(); + } + + @override + String? onCopy() => cellBloc.state.content; + + @override + Future focusChanged() async { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text.trim()) { + cellBloc + .add(TimeCellEvent.updateCell(_textEditingController.text.trim())); + } + return super.focusChanged(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart new file mode 100644 index 0000000000..08ab04c7c7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class MobileGridTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15), + decoration: const InputDecoration( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + isCollapsed: true, + ), + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart new file mode 100644 index 0000000000..159f2063a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class MobileRowDetailTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 16), + decoration: InputDecoration( + enabledBorder: + _getInputBorder(color: Theme.of(context).colorScheme.outline), + focusedBorder: + _getInputBorder(color: Theme.of(context).colorScheme.primary), + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + isCollapsed: true, + isDense: true, + constraints: const BoxConstraints(), + ), + // close keyboard when tapping outside of the text field + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } + + InputBorder _getInputBorder({Color? color}) { + return OutlineInputBorder( + borderSide: BorderSide(color: color!), + borderRadius: const BorderRadius.all(Radius.circular(14)), + gapPadding: 0, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart index afcb7da38f..94ed2d8405 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart @@ -1,10 +1,11 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; typedef SelectFieldCallback = void Function(FieldType); @@ -21,6 +22,7 @@ const List _supportedFieldTypes = [ FieldType.CreatedTime, FieldType.Relation, FieldType.Summary, + FieldType.Time, FieldType.Translate, ]; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart index 8ec91bdbaf..e4bcdd4911 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart @@ -1,9 +1,10 @@ import 'dart:typed_data'; +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/database/widgets/field/type_option_editor/translate.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flutter/material.dart'; import 'checkbox.dart'; import 'checklist.dart'; @@ -14,6 +15,7 @@ import 'relation.dart'; import 'rich_text.dart'; import 'single_select.dart'; import 'summary.dart'; +import 'time.dart'; import 'timestamp.dart'; import 'url.dart'; @@ -34,6 +36,7 @@ abstract class TypeOptionEditorFactory { FieldType.Checklist => const ChecklistTypeOptionEditorFactory(), FieldType.Relation => const RelationTypeOptionEditorFactory(), FieldType.Summary => const SummaryTypeOptionEditorFactory(), + FieldType.Time => const TimeTypeOptionEditorFactory(), FieldType.Translate => const TranslateTypeOptionEditorFactory(), _ => throw UnimplementedError(), }; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart new file mode 100644 index 0000000000..01a8c519c2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import 'builder.dart'; + +class TimeTypeOptionEditorFactory implements TypeOptionEditorFactory { + const TimeTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 99812b7c7f..f36c2fe264 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -22,6 +22,7 @@ extension FieldTypeExtension on FieldType { FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(), FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(), FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(), + FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(), FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(), _ => throw UnimplementedError(), }; @@ -39,6 +40,7 @@ extension FieldTypeExtension on FieldType { FieldType.CreatedTime => FlowySvgs.created_at_s, FieldType.Relation => FlowySvgs.relation_s, FieldType.Summary => FlowySvgs.ai_summary_s, + FieldType.Time => FlowySvgs.timer_start_s, FieldType.Translate => FlowySvgs.ai_translate_s, _ => throw UnimplementedError(), }; @@ -62,6 +64,7 @@ extension FieldTypeExtension on FieldType { FieldType.Checklist => const Color(0xFF98F4CD), FieldType.Relation => const Color(0xFFFDEDA7), FieldType.Summary => const Color(0xFFBECCFF), + FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFFBECCFF), _ => throw UnimplementedError(), }; @@ -80,6 +83,7 @@ extension FieldTypeExtension on FieldType { FieldType.Checklist => const Color(0xFF42AD93), FieldType.Relation => const Color(0xFFFDEDA7), FieldType.Summary => const Color(0xFF6859A7), + FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFF6859A7), _ => throw UnimplementedError(), }; diff --git a/frontend/appflowy_flutter/lib/util/time.dart b/frontend/appflowy_flutter/lib/util/time.dart new file mode 100644 index 0000000000..cdeb9834fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/time.dart @@ -0,0 +1,43 @@ +final RegExp timerRegExp = + RegExp(r'(?:(?\d*)h)? ?(?:(?\d*)m)?'); + +int? parseTime(String timerStr) { + int? res = int.tryParse(timerStr); + if (res != null) { + return res; + } + + final matches = timerRegExp.firstMatch(timerStr); + if (matches == null) { + return null; + } + final hours = int.tryParse(matches.namedGroup('hours') ?? ""); + final minutes = int.tryParse(matches.namedGroup('minutes') ?? ""); + if (hours == null && minutes == null) { + return null; + } + + final expected = + "${hours != null ? '${hours}h' : ''}${hours != null && minutes != null ? ' ' : ''}${minutes != null ? '${minutes}m' : ''}"; + if (timerStr != expected) { + return null; + } + + res = 0; + res += hours != null ? hours * 60 : res; + res += minutes ?? 0; + + return res; +} + +String formatTime(int minutes) { + if (minutes >= 60) { + if (minutes % 60 == 0) { + return "${minutes ~/ 60}h"; + } + return "${minutes ~/ 60}h ${minutes % 60}m"; + } else if (minutes >= 0) { + return "${minutes}m"; + } + return ""; +} diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 8ad8a3f762..ffdf26e42a 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -1041,6 +1041,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + intl_utils: + dependency: transitive + description: + name: intl_utils + sha256: c2b1f5c72c25512cbeef5ab015c008fc50fe7e04813ba5541c25272300484bf4 + url: "https://pub.dev" + source: hosted + version: "2.8.7" io: dependency: transitive description: @@ -2192,6 +2200,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_html: + dependency: transitive + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" universal_platform: dependency: transitive description: diff --git a/frontend/appflowy_flutter/test/unit_test/util/time.dart b/frontend/appflowy_flutter/test/unit_test/util/time.dart new file mode 100644 index 0000000000..ca4f2b8230 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/util/time.dart @@ -0,0 +1,24 @@ +import 'package:appflowy/util/time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parseTime should parse time string to minutes', () { + expect(parseTime('10'), 10); + expect(parseTime('70m'), 70); + expect(parseTime('4h 20m'), 260); + expect(parseTime('1h 80m'), 140); + expect(parseTime('asffsa2h3m'), null); + expect(parseTime('2h3m'), null); + expect(parseTime('blah'), null); + expect(parseTime('10a'), null); + expect(parseTime('2h'), 120); + }); + + test('formatTime should format time minutes to formatted string', () { + expect(formatTime(5), "5m"); + expect(formatTime(75), "1h 15m"); + expect(formatTime(120), "2h"); + expect(formatTime(-50), ""); + expect(formatTime(0), "0m"); + }); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 4c00612fec..56ce12e546 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -30,7 +30,6 @@ "passwordHint": "Password", "repeatPasswordHint": "Repeat password", "signUpWith": "Sign up with:" - }, "signIn": { "loginTitle": "Login to @:appName", @@ -1012,6 +1011,7 @@ "checklistFieldName": "Checklist", "relationFieldName": "Relation", "summaryFieldName": "AI Summary", + "timeFieldName": "Time", "translateFieldName": "AI Translate", "translateTo": "Translate to", "numberFormat": "Number format", @@ -1915,4 +1915,3 @@ "title": "Spaces" } } - diff --git a/frontend/rust-lib/event-integration-test/src/database_event.rs b/frontend/rust-lib/event-integration-test/src/database_event.rs index 12c76387c6..79b8b528d2 100644 --- a/frontend/rust-lib/event-integration-test/src/database_event.rs +++ b/frontend/rust-lib/event-integration-test/src/database_event.rs @@ -657,6 +657,12 @@ impl<'a> TestRowBuilder<'a> { checklist_field.id.clone() } + pub fn insert_time_cell(&mut self, time: i64) -> String { + let time_field = self.field_with_type(&FieldType::Time); + self.cell_build.insert_number_cell(&time_field.id, time); + time_field.id.clone() + } + pub fn field_with_type(&self, field_type: &FieldType) -> Field { self .fields diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs index 3a1dd63b2f..3263986db8 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -450,6 +450,7 @@ pub enum FieldType { Relation = 10, Summary = 11, Translate = 12, + Time = 13, } impl Display for FieldType { @@ -491,6 +492,7 @@ impl FieldType { FieldType::Relation => "Relation", FieldType::Summary => "Summarize", FieldType::Translate => "Translate", + FieldType::Time => "Time", }; s.to_string() } @@ -547,6 +549,10 @@ impl FieldType { matches!(self, FieldType::Relation) } + pub fn is_time(&self) -> bool { + matches!(self, FieldType::Time) + } + pub fn can_be_group(&self) -> bool { self.is_select_option() || self.is_checkbox() || self.is_url() } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs index 7840bd4ff6..a6a990a458 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs @@ -6,6 +6,7 @@ mod number_filter; mod relation_filter; mod select_option_filter; mod text_filter; +mod time_filter; mod util; pub use checkbox_filter::*; @@ -16,4 +17,5 @@ pub use number_filter::*; pub use relation_filter::*; pub use select_option_filter::*; pub use text_filter::*; +pub use time_filter::*; pub use util::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs new file mode 100644 index 0000000000..bf1f734450 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs @@ -0,0 +1,23 @@ +use flowy_derive::ProtoBuf; + +use crate::entities::NumberFilterConditionPB; +use crate::services::filter::ParseFilterData; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct TimeFilterPB { + #[pb(index = 1)] + pub condition: NumberFilterConditionPB, + + #[pb(index = 2)] + pub content: String, +} + +impl ParseFilterData for TimeFilterPB { + fn parse(condition: u8, content: String) -> Self { + TimeFilterPB { + condition: NumberFilterConditionPB::try_from(condition) + .unwrap_or(NumberFilterConditionPB::Equal), + content, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs index d4f3bedb7c..af1288506e 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs @@ -10,7 +10,7 @@ use validator::Validate; use crate::entities::{ CheckboxFilterPB, ChecklistFilterPB, DateFilterPB, FieldType, NumberFilterPB, RelationFilterPB, - SelectOptionFilterPB, TextFilterPB, + SelectOptionFilterPB, TextFilterPB, TimeFilterPB, }; use crate::services::filter::{Filter, FilterChangeset, FilterInner}; @@ -109,6 +109,10 @@ impl From<&Filter> for FilterPB { .cloned::() .unwrap() .try_into(), + FieldType::Time => condition_and_content + .cloned::() + .unwrap() + .try_into(), FieldType::Translate => condition_and_content .cloned::() .unwrap() @@ -160,6 +164,9 @@ impl TryFrom for FilterInner { FieldType::Summary => { BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) }, + FieldType::Time => { + BoxAny::new(TimeFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, FieldType::Translate => { BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) }, diff --git a/frontend/rust-lib/flowy-database2/src/entities/macros.rs b/frontend/rust-lib/flowy-database2/src/entities/macros.rs index 35c594a07e..2d30eb15f0 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/macros.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/macros.rs @@ -17,6 +17,7 @@ macro_rules! impl_into_field_type { 10 => FieldType::Relation, 11 => FieldType::Summary, 12 => FieldType::Translate, + 13 => FieldType::Time, _ => { tracing::error!("🔴Can't parse FieldType from value: {}", ty); FieldType::RichText diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs index ceeeab3874..f92072eabd 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs @@ -6,6 +6,7 @@ mod relation_entities; mod select_option_entities; mod summary_entities; mod text_entities; +mod time_entities; mod timestamp_entities; mod translate_entities; mod url_entities; @@ -18,6 +19,7 @@ pub use relation_entities::*; pub use select_option_entities::*; pub use summary_entities::*; pub use text_entities::*; +pub use time_entities::*; pub use timestamp_entities::*; pub use translate_entities::*; pub use url_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs new file mode 100644 index 0000000000..fdb3bdb6fd --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs @@ -0,0 +1,28 @@ +use crate::services::field::TimeTypeOption; +use flowy_derive::ProtoBuf; + +#[derive(Clone, Debug, Default, ProtoBuf)] +pub struct TimeTypeOptionPB { + #[pb(index = 1)] + pub dummy: String, +} + +impl From for TimeTypeOptionPB { + fn from(_data: TimeTypeOption) -> Self { + Self { + dummy: "".to_string(), + } + } +} + +impl From for TimeTypeOption { + fn from(_data: TimeTypeOptionPB) -> Self { + Self + } +} + +#[derive(Clone, Debug, Default, ProtoBuf)] +pub struct TimeCellDataPB { + #[pb(index = 2)] + pub time: i64, +} diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs index 068eea5da4..d1bae644ea 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs @@ -222,7 +222,7 @@ impl<'a> CellBuilder<'a> { FieldType::RichText => { cells.insert(field_id, insert_text_cell(cell_str, field)); }, - FieldType::Number => { + FieldType::Number | FieldType::Time => { if let Ok(num) = cell_str.parse::() { cells.insert(field_id, insert_number_cell(num, field)); } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs index 49451f3820..a6515c9db4 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs @@ -6,6 +6,7 @@ pub mod relation_type_option; pub mod selection_type_option; pub mod summary_type_option; pub mod text_type_option; +pub mod time_type_option; pub mod timestamp_type_option; pub mod translate_type_option; mod type_option; @@ -20,6 +21,7 @@ pub use number_type_option::*; pub use relation_type_option::*; pub use selection_type_option::*; pub use text_type_option::*; +pub use time_type_option::*; pub use timestamp_type_option::*; pub use type_option::*; pub use type_option_cell::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs index 1627b04465..5cb2875de5 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs @@ -79,13 +79,14 @@ impl CellDataDecoder for RichTextTypeOption { | FieldType::SingleSelect | FieldType::MultiSelect | FieldType::Checkbox - | FieldType::URL => Some(StringCellData::from(stringify_cell(cell, field))), + | FieldType::URL + | FieldType::Summary + | FieldType::Translate + | FieldType::Time => Some(StringCellData::from(stringify_cell(cell, field))), FieldType::Checklist | FieldType::LastEditedTime | FieldType::CreatedTime | FieldType::Relation => None, - FieldType::Summary => Some(StringCellData::from(stringify_cell(cell, field))), - FieldType::Translate => Some(StringCellData::from(stringify_cell(cell, field))), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs new file mode 100644 index 0000000000..d64ecf45a3 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs @@ -0,0 +1,6 @@ +mod time; +mod time_entities; +mod time_filter; + +pub use time::*; +pub use time_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs new file mode 100644 index 0000000000..0b7c141cb8 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs @@ -0,0 +1,115 @@ +use crate::entities::{TimeCellDataPB, TimeFilterPB}; +use crate::services::cell::{CellDataChangeset, CellDataDecoder}; +use crate::services::field::{ + TimeCellData, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, + TypeOptionCellDataSerde, TypeOptionTransform, +}; +use crate::services::sort::SortCondition; +use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder}; +use collab_database::rows::Cell; +use flowy_error::FlowyResult; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct TimeTypeOption; + +impl TypeOption for TimeTypeOption { + type CellData = TimeCellData; + type CellChangeset = TimeCellChangeset; + type CellProtobufType = TimeCellDataPB; + type CellFilter = TimeFilterPB; +} + +impl From for TimeTypeOption { + fn from(_data: TypeOptionData) -> Self { + Self + } +} + +impl From for TypeOptionData { + fn from(_data: TimeTypeOption) -> Self { + TypeOptionDataBuilder::new().build() + } +} + +impl TypeOptionCellDataSerde for TimeTypeOption { + fn protobuf_encode( + &self, + cell_data: ::CellData, + ) -> ::CellProtobufType { + if let Some(time) = cell_data.0 { + return TimeCellDataPB { time }; + } + TimeCellDataPB { + time: i64::default(), + } + } + + fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { + Ok(TimeCellData::from(cell)) + } +} + +impl TimeTypeOption { + pub fn new() -> Self { + Self + } +} + +impl TypeOptionTransform for TimeTypeOption {} + +impl CellDataDecoder for TimeTypeOption { + fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { + self.parse_cell(cell) + } + + fn stringify_cell_data(&self, cell_data: ::CellData) -> String { + if let Some(time) = cell_data.0 { + return time.to_string(); + } + "".to_string() + } + + fn numeric_cell(&self, cell: &Cell) -> Option { + let time_cell_data = self.parse_cell(cell).ok()?; + Some(time_cell_data.0.unwrap() as f64) + } +} + +pub type TimeCellChangeset = String; + +impl CellDataChangeset for TimeTypeOption { + fn apply_changeset( + &self, + changeset: ::CellChangeset, + _cell: Option, + ) -> FlowyResult<(Cell, ::CellData)> { + let str = changeset.trim().to_string(); + let cell_data = TimeCellData(str.parse::().ok()); + + Ok((Cell::from(&cell_data), cell_data)) + } +} + +impl TypeOptionCellDataFilter for TimeTypeOption { + fn apply_filter( + &self, + filter: &::CellFilter, + cell_data: &::CellData, + ) -> bool { + filter.is_visible(cell_data.0) + } +} + +impl TypeOptionCellDataCompare for TimeTypeOption { + fn apply_cmp( + &self, + cell_data: &::CellData, + other_cell_data: &::CellData, + sort_condition: SortCondition, + ) -> Ordering { + let order = cell_data.0.cmp(&other_cell_data.0); + sort_condition.evaluate_order(order) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_entities.rs new file mode 100644 index 0000000000..6084c80b5f --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_entities.rs @@ -0,0 +1,47 @@ +use crate::entities::FieldType; +use crate::services::field::{TypeOptionCellData, CELL_DATA}; +use collab::core::any_map::AnyMapExtension; +use collab_database::rows::{new_cell_builder, Cell}; + +#[derive(Clone, Debug, Default)] +pub struct TimeCellData(pub Option); + +impl TypeOptionCellData for TimeCellData { + fn is_cell_empty(&self) -> bool { + self.0.is_none() + } +} + +impl From<&Cell> for TimeCellData { + fn from(cell: &Cell) -> Self { + Self( + cell + .get_str_value(CELL_DATA) + .and_then(|data| data.parse::().ok()), + ) + } +} + +impl std::convert::From for TimeCellData { + fn from(s: String) -> Self { + Self(s.trim().to_string().parse::().ok()) + } +} + +impl ToString for TimeCellData { + fn to_string(&self) -> String { + if let Some(time) = self.0 { + time.to_string() + } else { + "".to_string() + } + } +} + +impl From<&TimeCellData> for Cell { + fn from(data: &TimeCellData) -> Self { + new_cell_builder(FieldType::Time) + .insert_str_value(CELL_DATA, data.to_string()) + .build() + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs new file mode 100644 index 0000000000..0620317dc0 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs @@ -0,0 +1,72 @@ +use collab_database::fields::Field; +use collab_database::rows::Cell; + +use crate::entities::{NumberFilterConditionPB, TimeFilterPB}; +use crate::services::cell::insert_text_cell; +use crate::services::filter::PreFillCellsWithFilter; + +impl TimeFilterPB { + pub fn is_visible(&self, cell_time: Option) -> bool { + if self.content.is_empty() { + match self.condition { + NumberFilterConditionPB::NumberIsEmpty => { + return cell_time.is_none(); + }, + NumberFilterConditionPB::NumberIsNotEmpty => { + return cell_time.is_some(); + }, + _ => {}, + } + } + + if cell_time.is_none() { + return false; + } + + let time = cell_time.unwrap(); + let content_time = self.content.parse::().unwrap_or_default(); + match self.condition { + NumberFilterConditionPB::Equal => time == content_time, + NumberFilterConditionPB::NotEqual => time != content_time, + NumberFilterConditionPB::GreaterThan => time > content_time, + NumberFilterConditionPB::LessThan => time < content_time, + NumberFilterConditionPB::GreaterThanOrEqualTo => time >= content_time, + NumberFilterConditionPB::LessThanOrEqualTo => time <= content_time, + _ => true, + } + } +} + +impl PreFillCellsWithFilter for TimeFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let expected_decimal = || self.content.parse::().ok(); + + let text = match self.condition { + NumberFilterConditionPB::Equal + | NumberFilterConditionPB::GreaterThanOrEqualTo + | NumberFilterConditionPB::LessThanOrEqualTo + if !self.content.is_empty() => + { + Some(self.content.clone()) + }, + NumberFilterConditionPB::GreaterThan if !self.content.is_empty() => { + expected_decimal().map(|value| { + let answer = value + 1; + answer.to_string() + }) + }, + NumberFilterConditionPB::LessThan if !self.content.is_empty() => { + expected_decimal().map(|value| { + let answer = value - 1; + answer.to_string() + }) + }, + _ => None, + }; + + let open_after_create = matches!(self.condition, NumberFilterConditionPB::NumberIsNotEmpty); + + // use `insert_text_cell` because self.content might not be a parsable i64. + (text.map(|s| insert_text_cell(s, field)), open_after_create) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs index c283d39bbc..a8b9d13b7e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs @@ -11,7 +11,7 @@ use flowy_error::FlowyResult; use crate::entities::{ CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB, - SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimestampTypeOptionPB, + SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimeTypeOptionPB, TimestampTypeOptionPB, TranslateTypeOptionPB, URLTypeOptionPB, }; use crate::services::cell::CellDataDecoder; @@ -20,7 +20,7 @@ use crate::services::field::summary_type_option::summary::SummarizationTypeOptio use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::{ CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption, - RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, URLTypeOption, + RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, TimestampTypeOption, URLTypeOption, }; use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; use crate::services::sort::SortCondition; @@ -187,6 +187,7 @@ pub fn type_option_data_from_pb>( FieldType::Summary => { SummarizationTypeOptionPB::try_from(bytes).map(|pb| SummarizationTypeOption::from(pb).into()) }, + FieldType::Time => TimeTypeOptionPB::try_from(bytes).map(|pb| TimeTypeOption::from(pb).into()), FieldType::Translate => { TranslateTypeOptionPB::try_from(bytes).map(|pb| TranslateTypeOption::from(pb).into()) }, @@ -257,6 +258,10 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) -> .try_into() .unwrap() }, + FieldType::Time => { + let time_type_option: TimeTypeOption = type_option.into(); + TimeTypeOptionPB::from(time_type_option).try_into().unwrap() + }, FieldType::Translate => { let translate_type_option: TranslateTypeOption = type_option.into(); TranslateTypeOptionPB::from(translate_type_option) @@ -284,5 +289,6 @@ pub fn default_type_option_data_from_type(field_type: FieldType) -> TypeOptionDa FieldType::Relation => RelationTypeOption::default().into(), FieldType::Summary => SummarizationTypeOption::default().into(), FieldType::Translate => TranslateTypeOption::default().into(), + FieldType::Time => TimeTypeOption.into(), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs index 19f7faf31b..415f694164 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs @@ -14,9 +14,9 @@ use crate::services::field::summary_type_option::summary::SummarizationTypeOptio use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::{ CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, - RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, TypeOption, - TypeOptionCellData, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, - TypeOptionTransform, URLTypeOption, + RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, + TimestampTypeOption, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption, }; use crate::services::sort::SortCondition; @@ -450,6 +450,16 @@ impl<'a> TypeOptionCellExt<'a> { self.cell_data_cache.clone(), ) }), + FieldType::Time => self + .field + .get_type_option::(field_type) + .map(|type_option| { + TypeOptionCellDataHandlerImpl::new_with_boxed( + type_option, + field_type, + self.cell_data_cache.clone(), + ) + }), FieldType::Translate => self .field .get_type_option::(field_type) @@ -563,6 +573,9 @@ fn get_type_option_transform_handler( }, FieldType::Summary => Box::new(SummarizationTypeOption::from(type_option_data)) as Box, + FieldType::Time => { + Box::new(TimeTypeOption::from(type_option_data)) as Box + }, FieldType::Translate => { Box::new(TranslateTypeOption::from(type_option_data)) as Box }, diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs index 8c7ef9bcb1..975faa0995 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs @@ -303,6 +303,10 @@ impl FilterController { let filter = condition_and_content.cloned::().unwrap(); filter.get_compliant_cell(field) }, + FieldType::Time => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, _ => (None, false), }; diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs index 3b7d6444ef..718d062fbb 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs @@ -12,6 +12,7 @@ use lib_infra::box_any::BoxAny; use crate::entities::{ CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType, InsertedRowPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB, + TimeFilterPB, }; use crate::services::field::SelectOptionIds; @@ -282,6 +283,7 @@ impl FilterInner { FieldType::Relation => BoxAny::new(RelationFilterPB::parse(condition as u8, content)), FieldType::Summary => BoxAny::new(TextFilterPB::parse(condition as u8, content)), FieldType::Translate => BoxAny::new(TextFilterPB::parse(condition as u8, content)), + FieldType::Time => BoxAny::new(TimeFilterPB::parse(condition as u8, content)), }; FilterInner::Data { @@ -368,6 +370,10 @@ impl<'a> From<&'a Filter> for FilterMap { let filter = condition_and_content.cloned::()?; (filter.condition as u8, filter.content) }, + FieldType::Time => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, FieldType::Translate => { let filter = condition_and_content.cloned::()?; (filter.condition as u8, filter.content) diff --git a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs index 2ed9db16ff..1c1f633e47 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs @@ -4,7 +4,7 @@ use flowy_database2::entities::FieldType; use flowy_database2::services::field::{ ChecklistCellChangeset, DateCellChangeset, DateCellData, MultiSelectTypeOption, RelationCellChangeset, SelectOptionCellChangeset, SingleSelectTypeOption, StringCellData, - URLCellData, + TimeCellData, URLCellData, }; use lib_infra::box_any::BoxAny; @@ -200,3 +200,20 @@ async fn update_updated_at_field_on_other_cell_update() { } } } + +#[tokio::test] +async fn time_cell_data_test() { + let test = DatabaseCellTest::new().await; + let time_field = test.get_first_field(FieldType::Time); + let cells = test + .editor + .get_cells_for_field(&test.view_id, &time_field.id) + .await; + + if let Some(cell) = cells[0].cell.as_ref() { + let cell = TimeCellData::from(cell); + + assert!(cell.0.is_some()); + assert_eq!(cell.0.unwrap_or_default(), 75); + } +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs index e53be13266..7cd9f9f3d1 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs @@ -40,6 +40,26 @@ async fn grid_create_field() { }, ]; test.run_scripts(scripts).await; + + let (params, field) = create_time_field(&test.view_id()); + let scripts = vec![ + CreateField { params }, + AssertFieldTypeOptionEqual { + field_index: test.field_count(), + expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(), + }, + ]; + test.run_scripts(scripts).await; + + let (params, field) = create_time_field(&test.view_id()); + let scripts = vec![ + CreateField { params }, + AssertFieldTypeOptionEqual { + field_index: test.field_count(), + expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(), + }, + ]; + test.run_scripts(scripts).await; } #[tokio::test] diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs index a5f2703869..a648f7a442 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs @@ -4,7 +4,7 @@ use collab_database::views::OrderObjectPosition; use flowy_database2::entities::{CreateFieldParams, FieldType}; use flowy_database2::services::field::{ type_option_to_pb, DateFormat, DateTypeOption, FieldBuilder, RichTextTypeOption, SelectOption, - SingleSelectTypeOption, TimeFormat, TimestampTypeOption, + SingleSelectTypeOption, TimeFormat, TimeTypeOption, TimestampTypeOption, }; pub fn create_text_field(grid_id: &str) -> (CreateFieldParams, Field) { @@ -98,3 +98,21 @@ pub fn create_timestamp_field(grid_id: &str, field_type: FieldType) -> (CreateFi }; (params, field) } + +pub fn create_time_field(grid_id: &str) -> (CreateFieldParams, Field) { + let field_type = FieldType::Time; + let type_option = TimeTypeOption; + let text_field = FieldBuilder::new(field_type, type_option.clone()) + .name("Time field") + .build(); + + let type_option_data = type_option_to_pb(type_option.into(), &field_type).to_vec(); + let params = CreateFieldParams { + view_id: grid_id.to_owned(), + field_type, + type_option_data: Some(type_option_data), + field_name: None, + position: OrderObjectPosition::default(), + }; + (params, text_field) +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs index bf5d1513c9..e99cc725d5 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs @@ -6,3 +6,4 @@ mod number_filter_test; mod script; mod select_option_filter_test; mod text_filter_test; +mod time_filter_test; diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs new file mode 100644 index 0000000000..503483a7b5 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs @@ -0,0 +1,121 @@ +use flowy_database2::entities::{FieldType, NumberFilterConditionPB, TimeFilterPB}; +use lib_infra::box_any::BoxAny; + +use crate::database::filter_test::script::FilterScript::*; +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; + +#[tokio::test] +async fn grid_filter_time_is_equal_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.row_details.len(); + let expected = 1; + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Time, + data: BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::Equal, + content: "75".to_string(), + }), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + }, + AssertNumberOfVisibleRows { expected }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn grid_filter_time_is_less_than_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.row_details.len(); + let expected = 1; + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Time, + + data: BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::LessThan, + content: "80".to_string(), + }), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + }, + AssertNumberOfVisibleRows { expected }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn grid_filter_time_is_less_than_or_equal_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.row_details.len(); + let expected = 1; + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Time, + data: BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::LessThanOrEqualTo, + content: "75".to_string(), + }), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + }, + AssertNumberOfVisibleRows { expected }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn grid_filter_time_is_empty_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.row_details.len(); + let expected = 6; + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Time, + data: BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::NumberIsEmpty, + content: "".to_string(), + }), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + }, + AssertNumberOfVisibleRows { expected }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn grid_filter_time_is_not_empty_test() { + let mut test = DatabaseFilterTest::new().await; + let row_count = test.row_details.len(); + let expected = 1; + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Time, + data: BoxAny::new(TimeFilterPB { + condition: NumberFilterConditionPB::NumberIsNotEmpty, + content: "".to_string(), + }), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + }, + AssertNumberOfVisibleRows { expected }, + ]; + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index 70d3cd77a3..d722a352f3 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs @@ -134,6 +134,12 @@ pub fn make_test_board() -> DatabaseData { .build(); fields.push(relation_field); }, + FieldType::Time => { + let time_field = FieldBuilder::from_field_type(field_type) + .name("Estimated time") + .build(); + fields.push(time_field); + }, FieldType::Translate => {}, } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs index 0666d9171c..6896e47ccb 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs @@ -10,7 +10,7 @@ use flowy_database2::services::field::translate_type_option::translate::Translat use flowy_database2::services::field::{ ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, NumberFormat, NumberTypeOption, RelationTypeOption, SelectOption, SelectOptionColor, - SingleSelectTypeOption, TimeFormat, TimestampTypeOption, + SingleSelectTypeOption, TimeFormat, TimeTypeOption, TimestampTypeOption, }; use flowy_database2::services::field_settings::default_field_settings_for_fields; @@ -133,6 +133,13 @@ pub fn make_test_grid() -> DatabaseData { .build(); fields.push(relation_field); }, + FieldType::Time => { + let type_option = TimeTypeOption; + let time_field = FieldBuilder::new(field_type, type_option) + .name("Estimated time") + .build(); + fields.push(time_field); + }, FieldType::Translate => { let type_option = TranslateTypeOption { auto_fill: false, @@ -168,6 +175,7 @@ pub fn make_test_grid() -> DatabaseData { FieldType::Checklist => { row_builder.insert_checklist_cell(vec![("First thing".to_string(), false)]) }, + FieldType::Time => row_builder.insert_time_cell(75), _ => "".to_owned(), }; } diff --git a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs index 5297ff14de..3fbb0aafe2 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs @@ -83,6 +83,7 @@ async fn export_and_then_import_meta_csv_test() { FieldType::CreatedTime => {}, FieldType::Relation => {}, FieldType::Summary => {}, + FieldType::Time => {}, FieldType::Translate => {}, } } else { @@ -167,6 +168,7 @@ async fn history_database_import_test() { FieldType::CreatedTime => {}, FieldType::Relation => {}, FieldType::Summary => {}, + FieldType::Time => {}, FieldType::Translate => {}, } } else { From 6be9c001276ab20bf60447bcd26516c1944c0c13 Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 13 Jun 2024 15:14:20 +0800 Subject: [PATCH 5/5] chore: update to latest client api (#5529) --- frontend/appflowy_tauri/src-tauri/Cargo.toml | 5 +- frontend/appflowy_web/wasm-libs/Cargo.toml | 5 +- .../appflowy_web_app/src-tauri/Cargo.toml | 5 +- frontend/rust-lib/Cargo.lock | 37 ++++-------- frontend/rust-lib/Cargo.toml | 6 +- frontend/rust-lib/flowy-server/Cargo.toml | 1 - .../af_cloud/impls/user/cloud_service_impl.rs | 56 +++++++++---------- 7 files changed, 41 insertions(+), 74 deletions(-) diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index c21aa283d9..ed266154e6 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { version = "0.2" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9d3d28ad8937712cc688c20be7c0ee6e4d14a168" } [dependencies] serde_json.workspace = true @@ -113,6 +113,3 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } - -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } -shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml index 6f3221b392..1562e0b0ed 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ b/frontend/appflowy_web/wasm-libs/Cargo.toml @@ -55,7 +55,7 @@ yrs = "0.18.8" # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { version = "0.2" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9d3d28ad8937712cc688c20be7c0ee6e4d14a168" } [profile.dev] opt-level = 0 @@ -75,6 +75,3 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "6febf0397e66ebf0a281980a2e7602d7af00c975" } - -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } -shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 54e0f74644..99da587196 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ collab-user = { version = "0.2" } # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { version = "0.2" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9d3d28ad8937712cc688c20be7c0ee6e4d14a168" } [dependencies] serde_json.workspace = true @@ -114,6 +114,3 @@ collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFl collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "3a58d95" } - -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } -shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 17bd8e1646..5e3d4f75de 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "bincode", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "appflowy-ai-client" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "bytes", @@ -194,20 +194,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "appflowy-cloud-billing-client" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud-Billing-Client?rev=9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6#9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6" -dependencies = [ - "client-api", - "reqwest", - "serde", - "serde_json", - "shared-entity", - "tokio", - "yrs", -] - [[package]] name = "arc-swap" version = "1.7.1" @@ -678,7 +664,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.2.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "again", "anyhow", @@ -725,7 +711,7 @@ dependencies = [ [[package]] name = "client-websocket" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "futures-channel", "futures-util", @@ -934,7 +920,7 @@ dependencies = [ [[package]] name = "collab-rt-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "bincode", @@ -959,7 +945,7 @@ dependencies = [ [[package]] name = "collab-rt-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "async-trait", @@ -1279,7 +1265,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "app-error", @@ -2086,7 +2072,6 @@ name = "flowy-server" version = "0.1.0" dependencies = [ "anyhow", - "appflowy-cloud-billing-client", "assert-json-diff", "bytes", "chrono", @@ -2546,7 +2531,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "futures-util", @@ -2563,7 +2548,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "app-error", @@ -2928,7 +2913,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "reqwest", @@ -5012,7 +4997,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ff4384fbd07a4b7394a9af8c9159cd65715d3471#ff4384fbd07a4b7394a9af8c9159cd65715d3471" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=9d3d28ad8937712cc688c20be7c0ee6e4d14a168#9d3d28ad8937712cc688c20be7c0ee6e4d14a168" dependencies = [ "anyhow", "app-error", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 039f280eef..cf4dc6fcbc 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -94,8 +94,7 @@ yrs = "0.18.8" # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { version = "0.2" } -appflowy-cloud-billing-client = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud-Billing-Client", rev = "9f9c2d1ad180987a31d18c6c067a56a5fa1f6da6" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9d3d28ad8937712cc688c20be7c0ee6e4d14a168" } [profile.dev] opt-level = 1 @@ -122,9 +121,6 @@ lto = false incremental = false [patch.crates-io] -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } -shared-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ff4384fbd07a4b7394a9af8c9159cd65715d3471" } - # TODO(Lucas.Xu) Upgrade to the latest version of RocksDB once PR(https://github.com/rust-rocksdb/rust-rocksdb/pull/869) is merged. # Currently, using the following revision id. This commit is patched to fix the 32-bit build issue and it's checked out from 0.21.0, not 0.22.0. rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec131b9d82dc94e178fe8efc0c147b09" } diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index 1f15d85088..184731f204 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -37,7 +37,6 @@ flowy-user-pub = { workspace = true } flowy-folder-pub = { workspace = true } flowy-database-pub = { workspace = true } flowy-document-pub = { workspace = true } -appflowy-cloud-billing-client = { workspace = true } flowy-error = { workspace = true, features = ["impl_from_serde", "impl_from_reqwest", "impl_from_url", "impl_from_appflowy_cloud"] } flowy-server-pub = { workspace = true } flowy-search-pub = { workspace = true } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs index 7fc3e2bf1c..4c456f37fa 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs @@ -1,11 +1,10 @@ -use appflowy_cloud_billing_client::entities::{ - RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus, -}; use std::collections::HashMap; use std::sync::Arc; use anyhow::anyhow; -use appflowy_cloud_billing_client::BillingClient; +use client_api::entity::billing_dto::{ + SubscriptionPlan, SubscriptionStatus, WorkspaceSubscriptionPlan, WorkspaceSubscriptionStatus, +}; use client_api::entity::workspace_dto::{ CreateWorkspaceMember, CreateWorkspaceParam, PatchWorkspaceParam, WorkspaceMemberChangeset, WorkspaceMemberInvitation, @@ -491,7 +490,7 @@ where FutureResult::new(async move { let subscription_plan = to_workspace_subscription_plan(workspace_subscription_plan)?; let client = try_get_client?; - let payment_link = BillingClient::from(client.as_ref()) + let payment_link = client .create_subscription( &workspace_id, to_recurring_interval(recurring_interval), @@ -507,7 +506,7 @@ where let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let client = try_get_client?; - let workspace_subscriptions = BillingClient::from(client.as_ref()) + let workspace_subscriptions = client .list_subscription() .await? .into_iter() @@ -521,9 +520,7 @@ where let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let client = try_get_client?; - BillingClient::from(client.as_ref()) - .cancel_subscription(&workspace_id) - .await?; + client.cancel_subscription(&workspace_id).await?; Ok(()) }) } @@ -532,9 +529,7 @@ where let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let client = try_get_client?; - let usage = BillingClient::from(client.as_ref()) - .get_workspace_usage(&workspace_id) - .await?; + let usage = client.get_billing_workspace_usage(&workspace_id).await?; Ok(WorkspaceUsage { member_count: usage.member_count, member_count_limit: usage.member_count_limit, @@ -548,9 +543,7 @@ where let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let client = try_get_client?; - let url = BillingClient::from(client.as_ref()) - .get_portal_session_link() - .await?; + let url = client.get_portal_session_link().await?; Ok(url) }) } @@ -651,10 +644,16 @@ fn oauth_params_from_box_any(any: BoxAny) -> Result RecurringInterval { +fn to_recurring_interval( + r: flowy_user_pub::entities::RecurringInterval, +) -> client_api::entity::billing_dto::RecurringInterval { match r { - flowy_user_pub::entities::RecurringInterval::Month => RecurringInterval::Month, - flowy_user_pub::entities::RecurringInterval::Year => RecurringInterval::Year, + flowy_user_pub::entities::RecurringInterval::Month => { + client_api::entity::billing_dto::RecurringInterval::Month + }, + flowy_user_pub::entities::RecurringInterval::Year => { + client_api::entity::billing_dto::RecurringInterval::Year + }, } } @@ -675,22 +674,19 @@ fn to_workspace_subscription(s: WorkspaceSubscriptionStatus) -> WorkspaceSubscri WorkspaceSubscription { workspace_id: s.workspace_id, subscription_plan: match s.workspace_plan { - appflowy_cloud_billing_client::entities::WorkspaceSubscriptionPlan::Pro => { - flowy_user_pub::entities::SubscriptionPlan::Pro - }, - appflowy_cloud_billing_client::entities::WorkspaceSubscriptionPlan::Team => { - flowy_user_pub::entities::SubscriptionPlan::Team - }, + WorkspaceSubscriptionPlan::Pro => flowy_user_pub::entities::SubscriptionPlan::Pro, + WorkspaceSubscriptionPlan::Team => flowy_user_pub::entities::SubscriptionPlan::Team, _ => flowy_user_pub::entities::SubscriptionPlan::None, }, recurring_interval: match s.recurring_interval { - RecurringInterval::Month => flowy_user_pub::entities::RecurringInterval::Month, - RecurringInterval::Year => flowy_user_pub::entities::RecurringInterval::Year, + client_api::entity::billing_dto::RecurringInterval::Month => { + flowy_user_pub::entities::RecurringInterval::Month + }, + client_api::entity::billing_dto::RecurringInterval::Year => { + flowy_user_pub::entities::RecurringInterval::Year + }, }, - is_active: matches!( - s.subscription_status, - appflowy_cloud_billing_client::entities::SubscriptionStatus::Active - ), + is_active: matches!(s.subscription_status, SubscriptionStatus::Active), canceled_at: s.canceled_at, } }