From c8e86f4f26cf6874590a3e2f94444ed78d97cc16 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 5 Mar 2024 13:51:03 +0800 Subject: [PATCH] feat: open workspace, rename workspace and update workspace icon (#4818) * feat: support opening a workspace * feat: support renaming a workspace * fix: rename issue * fix: rename issues * feat: refactor menu bloc * test: add collaborative workspace test * test: add open workspace integration tet * test: add delete workspace integration tet * chore: turn off collab workspace feature --- .../integration_test/cloud/cloud_runner.dart | 7 +- .../cloud/collaborative_workspace_test.dart | 115 +++++++++ .../integration_test/util/base.dart | 23 +- .../util/common_operations.dart | 48 ++++ .../notification/notification_helper.dart | 2 +- .../favorite/mobile_favorite_folder.dart | 19 +- .../presentation/home/mobile_folders.dart | 21 +- .../mobile_home_personal_folder_header.dart | 6 +- .../mobile_notifications_page.dart | 15 +- .../lib/user/application/user_service.dart | 20 ++ .../workspace/application/menu/menu_bloc.dart | 139 ----------- .../workspace/application/menu/prelude.dart | 2 +- .../menu/sidebar_root_views_bloc.dart | 160 ++++++++++++ .../application/user/user_workspace_bloc.dart | 236 +++++++++++++++--- .../menu/sidebar/folder/personal_folder.dart | 6 +- .../home/menu/sidebar/sidebar.dart | 113 ++++++--- .../menu/sidebar/sidebar_new_page_button.dart | 6 +- .../home/menu/sidebar/sidebar_top_menu.dart | 4 +- .../home/menu/sidebar/sidebar_workspace.dart | 78 +++--- .../workspace/_sidebar_workspace_actions.dart | 29 ++- .../workspace/_sidebar_workspace_icon.dart | 46 +++- ...list.dart => _sidebar_workspace_menu.dart} | 81 +++--- .../appflowy_result/lib/appflowy_result.dart | 1 + .../appflowy_result/lib/src/async_result.dart | 33 +++ .../appflowy_result/lib/src/result.dart | 66 +++-- .../bloc_test/home_test/menu_bloc_test.dart | 31 ++- frontend/resources/translations/en.json | 6 +- 27 files changed, 946 insertions(+), 367 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart rename frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/{_sidebar_workspace_item_list.dart => _sidebar_workspace_menu.dart} (77%) create mode 100644 frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart diff --git a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart index b0ad36755b..2435d15c3d 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart @@ -1,8 +1,9 @@ -import 'empty_test.dart' as preset_af_cloud_env_test; +import 'anon_user_continue_test.dart' as anon_user_continue_test; import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test; +import 'collaborative_workspace_test.dart' as collaboration_workspace_test; +import 'empty_test.dart' as preset_af_cloud_env_test; // import 'document_sync_test.dart' as document_sync_test; import 'user_setting_sync_test.dart' as user_sync_test; -import 'anon_user_continue_test.dart' as anon_user_continue_test; Future main() async { preset_af_cloud_env_test.main(); @@ -14,4 +15,6 @@ Future main() async { user_sync_test.main(); anon_user_continue_test.main(); + + collaboration_workspace_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart new file mode 100644 index 0000000000..c2ed1cf049 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/cloud/collaborative_workspace_test.dart @@ -0,0 +1,115 @@ +// ignore_for_file: unused_import + +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../util/database_test_op.dart'; +import '../util/dir.dart'; +import '../util/emoji.dart'; +import '../util/mock/mock_file_picker.dart'; +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final email = '${uuid()}@appflowy.io'; + + group('collaborative workspace', () { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + // combine the create and delete workspace test to reduce the time + testWidgets('create a new workspace, open it and then delete it', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + await tester.createCollaborativeWorkspace(name); + + // see the success message + var success = find.text(LocaleKeys.workspace_createSuccess.tr()); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + + // check the create result + await tester.openCollaborativeWorkspaceMenu(); + var items = find.byType(WorkspaceMenuItem); + expect(items, findsNWidgets(2)); + expect( + tester.widget(items.last).workspace.name, + name, + ); + + // open the newly created workspace + await tester.tapButton(items.last); + success = find.text(LocaleKeys.workspace_openSuccess.tr()); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + + await tester.closeCollaborativeWorkspaceMenu(); + + // delete the newly created workspace + await tester.openCollaborativeWorkspaceMenu(); + 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()); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + }, + ); + + // check the result + await tester.openCollaborativeWorkspaceMenu(); + items = find.byType(WorkspaceMenuItem); + expect(items, findsOneWidget); + expect( + tester.widget(items.last).workspace.name != name, + true, + ); + await tester.closeCollaborativeWorkspaceMenu(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/util/base.dart b/frontend/appflowy_flutter/integration_test/util/base.dart index 11b0dcf1ba..df5b40f93a 100644 --- a/frontend/appflowy_flutter/integration_test/util/base.dart +++ b/frontend/appflowy_flutter/integration_test/util/base.dart @@ -134,8 +134,9 @@ extension AppFlowyTestBase on WidgetTester { Future pumpUntilFound( Finder finder, { Duration timeout = const Duration(seconds: 10), - Duration pumpInterval = - const Duration(milliseconds: 50), // Interval between pumps + Duration pumpInterval = const Duration( + milliseconds: 50, + ), // Interval between pumps }) async { bool timerDone = false; final timer = Timer(timeout, () => timerDone = true); @@ -148,6 +149,24 @@ extension AppFlowyTestBase on WidgetTester { timer.cancel(); } + Future pumpUntilNotFound( + Finder finder, { + Duration timeout = const Duration(seconds: 10), + Duration pumpInterval = const Duration( + milliseconds: 50, + ), // Interval between pumps + }) async { + bool timerDone = false; + final timer = Timer(timeout, () => timerDone = true); + while (!timerDone) { + await pump(pumpInterval); // Pump with an interval + if (!any(finder)) { + break; + } + } + timer.cancel(); + } + Future tapButton( Finder finder, { int? pointer, diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index 12b38cf78a..ab9cbdc0d0 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -6,10 +6,13 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; @@ -518,6 +521,51 @@ extension CommonOperations on WidgetTester { await pumpAndSettle(); } } + + Future openCollaborativeWorkspaceMenu() async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + + final workspace = find.byType(SidebarWorkspace); + expect(workspace, findsOneWidget); + // click it + await tapButton(workspace); + } + + Future closeCollaborativeWorkspaceMenu() async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + + await tapAt(Offset.zero); + await pumpAndSettle(); + } + + Future createCollaborativeWorkspace(String name) async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + await openCollaborativeWorkspaceMenu(); + // expect to see the workspace list, and there should be only one workspace + final workspacesMenu = find.byType(WorkspacesMenu); + expect(workspacesMenu, findsOneWidget); + + // click the create button + final createButton = find.byKey(createWorkspaceButtonKey); + expect(createButton, findsOneWidget); + await tapButton(createButton); + + // see the create workspace dialog + final createWorkspaceDialog = find.byType(CreateWorkspaceDialog); + expect(createWorkspaceDialog, findsOneWidget); + + // input the workspace name + await enterText(find.byType(TextField), name); + await pumpAndSettle(); + + await tapButtonWithName(LocaleKeys.button_ok.tr()); + } } extension ViewLayoutPBTest on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart b/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart index e91549641e..9aba14cd27 100644 --- a/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart +++ b/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; -class NotificationParser { +class NotificationParser { NotificationParser({ this.id, required this.callback, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart index 5c19b3d6be..d7d9b7993f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart @@ -3,7 +3,7 @@ import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -27,10 +27,13 @@ class MobileFavoritePageFolder extends StatelessWidget { return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => MenuBloc( - user: userProfile, - workspaceId: workspaceSetting.workspaceId, - )..add(const MenuEvent.initial()), + create: (_) => SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + userProfile, + workspaceSetting.workspaceId, + ), + ), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), @@ -38,11 +41,11 @@ class MobileFavoritePageFolder extends StatelessWidget { ], child: MultiBlocListener( listeners: [ - BlocListener( + BlocListener( listenWhen: (p, c) => - p.lastCreatedView?.id != c.lastCreatedView?.id, + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) => - context.pushView(state.lastCreatedView!), + context.pushView(state.lastCreatedRootView!), ), ], child: Builder( 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 da58fb8f5c..cc42b3c9b9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -1,7 +1,7 @@ import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -26,10 +26,13 @@ class MobileFolders extends StatelessWidget { return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => MenuBloc( - user: user, - workspaceId: workspaceSetting.workspaceId, - )..add(const MenuEvent.initial()), + create: (_) => SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + user, + workspaceSetting.workspaceId, + ), + ), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), @@ -37,16 +40,16 @@ class MobileFolders extends StatelessWidget { ], child: MultiBlocListener( listeners: [ - BlocListener( + BlocListener( listenWhen: (p, c) => - p.lastCreatedView?.id != c.lastCreatedView?.id, + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) => - context.pushView(state.lastCreatedView!), + context.pushView(state.lastCreatedRootView!), ), ], child: Builder( builder: (context) { - final menuState = context.watch().state; + final menuState = context.watch().state; return SlidableAutoCloseBehavior( child: Column( children: [ diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart index 91baaf4f68..6e77f86454 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -67,8 +67,8 @@ class _MobilePersonalFolderHeaderState size: Size.square(iconSize), ), onPressed: () { - context.read().add( - MenuEvent.createApp( + context.read().add( + SidebarRootViewsEvent.createRootView( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), index: 0, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart index 8156e8debe..f87ef8645f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -4,7 +4,7 @@ import 'package:appflowy/mobile/presentation/notifications/widgets/mobile_notifi import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/notifications/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; @@ -80,11 +80,14 @@ class _NotificationScreenContent extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => MenuBloc( - workspaceId: workspaceSetting.workspaceId, - user: userProfile, - )..add(const MenuEvent.initial()), - child: BlocBuilder( + create: (_) => SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + userProfile, + workspaceSetting.workspaceId, + ), + ), + child: BlocBuilder( builder: (context, menuState) => BlocBuilder( builder: (context, filterState) => diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index 1ef6d132e0..34673ae4f4 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -130,4 +130,24 @@ class UserBackendService { final request = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventDeleteWorkspace(request).send(); } + + Future> renameWorkspace( + String workspaceId, + String name, + ) { + final request = RenameWorkspacePB() + ..workspaceId = workspaceId + ..newName = name; + return UserEventRenameWorkspace(request).send(); + } + + Future> updateWorkspaceIcon( + String workspaceId, + String icon, + ) { + final request = ChangeWorkspaceIconPB() + ..workspaceId = workspaceId + ..newIcon = icon; + return UserEventChangeWorkspaceIcon(request).send(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart deleted file mode 100644 index c9ca94e0ef..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/workspace/application/workspace/workspace_listener.dart'; -import 'package:appflowy/workspace/application/workspace/workspace_service.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/user_profile.pb.dart'; -import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'menu_bloc.freezed.dart'; - -class MenuBloc extends Bloc { - MenuBloc({required this.user, required this.workspaceId}) - : _workspaceService = WorkspaceService(workspaceId: workspaceId), - _listener = WorkspaceListener( - user: user, - workspaceId: workspaceId, - ), - super(MenuState.initial()) { - _dispatch(); - } - - final WorkspaceService _workspaceService; - final WorkspaceListener _listener; - final UserProfilePB user; - final String workspaceId; - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } - - void _dispatch() { - on( - (event, emit) async { - await event.map( - initial: (e) async { - _listener.start(appsChanged: _handleAppsOrFail); - await _fetchApps(emit); - }, - createApp: (_CreateApp event) async { - final result = await _workspaceService.createApp( - name: event.name, - desc: event.desc, - index: event.index, - ); - result.fold( - (app) => emit(state.copyWith(lastCreatedView: app)), - (error) { - Log.error(error); - emit( - state.copyWith( - successOrFailure: FlowyResult.failure(error), - ), - ); - }, - ); - }, - didReceiveApps: (e) async { - emit( - e.appsOrFail.fold( - (views) => state.copyWith( - views: views, - successOrFailure: FlowyResult.success(null), - ), - (err) => - state.copyWith(successOrFailure: FlowyResult.failure(err)), - ), - ); - }, - moveApp: (_MoveApp value) { - if (state.views.length > value.fromIndex) { - final view = state.views[value.fromIndex]; - _workspaceService.moveApp( - appId: view.id, - fromIndex: value.fromIndex, - toIndex: value.toIndex, - ); - final apps = List.from(state.views); - - apps.insert(value.toIndex, apps.removeAt(value.fromIndex)); - emit(state.copyWith(views: apps)); - } - }, - ); - }, - ); - } - - // ignore: unused_element - Future _fetchApps(Emitter emit) async { - final viewsOrError = await _workspaceService.getViews(); - emit( - viewsOrError.fold( - (views) => state.copyWith(views: views), - (error) { - Log.error(error); - return state.copyWith(successOrFailure: FlowyResult.failure(error)); - }, - ), - ); - } - - void _handleAppsOrFail(FlowyResult, FlowyError> appsOrFail) { - appsOrFail.fold( - (apps) => add(MenuEvent.didReceiveApps(FlowyResult.success(apps))), - (error) => add(MenuEvent.didReceiveApps(FlowyResult.failure(error))), - ); - } -} - -@freezed -class MenuEvent with _$MenuEvent { - const factory MenuEvent.initial() = _Initial; - const factory MenuEvent.createApp(String name, {String? desc, int? index}) = - _CreateApp; - const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp; - const factory MenuEvent.didReceiveApps( - FlowyResult, FlowyError> appsOrFail, - ) = _ReceiveApps; -} - -@freezed -class MenuState with _$MenuState { - const factory MenuState({ - required List views, - required FlowyResult successOrFailure, - ViewPB? lastCreatedView, - }) = _MenuState; - - factory MenuState.initial() => MenuState( - views: [], - successOrFailure: FlowyResult.success(null), - ); -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart index 0bf94ea60b..7d24a56b0c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart @@ -1,2 +1,2 @@ -export 'menu_bloc.dart'; export 'menu_user_bloc.dart'; +export 'sidebar_root_views_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart new file mode 100644 index 0000000000..1ad50401b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/workspace/workspace_listener.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.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/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sidebar_root_views_bloc.freezed.dart'; + +class SidebarRootViewsBloc + extends Bloc { + SidebarRootViewsBloc() : super(SidebarRootViewState.initial()) { + _dispatch(); + } + + late WorkspaceService _workspaceService; + WorkspaceListener? _listener; + + @override + Future close() async { + await _listener?.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspaceId) async { + _initial(userProfile, workspaceId); + await _fetchApps(emit); + }, + reset: (userProfile, workspaceId) async { + await _listener?.stop(); + _initial(userProfile, workspaceId); + await _fetchApps(emit); + }, + createRootView: (name, desc, index) async { + final result = await _workspaceService.createApp( + name: name, + desc: desc, + index: index, + ); + result.fold( + (view) => emit(state.copyWith(lastCreatedRootView: view)), + (error) { + Log.error(error); + emit( + state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ); + }, + ); + }, + didReceiveViews: (viewsOrFailure) async { + emit( + viewsOrFailure.fold( + (views) => state.copyWith( + views: views, + successOrFailure: FlowyResult.success(null), + ), + (err) => + state.copyWith(successOrFailure: FlowyResult.failure(err)), + ), + ); + }, + moveRootView: (int fromIndex, int toIndex) { + if (state.views.length > fromIndex) { + final view = state.views[fromIndex]; + + _workspaceService.moveApp( + appId: view.id, + fromIndex: fromIndex, + toIndex: toIndex, + ); + + final views = List.from(state.views); + views.insert(toIndex, views.removeAt(fromIndex)); + emit(state.copyWith(views: views)); + } + }, + ); + }, + ); + } + + Future _fetchApps(Emitter emit) async { + final viewsOrError = await _workspaceService.getViews(); + emit( + viewsOrError.fold( + (views) => state.copyWith(views: views), + (error) { + Log.error(error); + return state.copyWith(successOrFailure: FlowyResult.failure(error)); + }, + ), + ); + } + + void _handleAppsOrFail(FlowyResult, FlowyError> viewsOrFail) { + viewsOrFail.fold( + (views) => add( + SidebarRootViewsEvent.didReceiveViews(FlowyResult.success(views)), + ), + (error) => add( + SidebarRootViewsEvent.didReceiveViews(FlowyResult.failure(error)), + ), + ); + } + + void _initial(UserProfilePB userProfile, String workspaceId) { + _workspaceService = WorkspaceService(workspaceId: workspaceId); + _listener = WorkspaceListener( + user: userProfile, + workspaceId: workspaceId, + )..start(appsChanged: _handleAppsOrFail); + } +} + +@freezed +class SidebarRootViewsEvent with _$SidebarRootViewsEvent { + const factory SidebarRootViewsEvent.initial( + UserProfilePB userProfile, + String workspaceId, + ) = _Initial; + const factory SidebarRootViewsEvent.reset( + UserProfilePB userProfile, + String workspaceId, + ) = _Reset; + const factory SidebarRootViewsEvent.createRootView( + String name, { + String? desc, + int? index, + }) = _createRootView; + const factory SidebarRootViewsEvent.moveRootView(int fromIndex, int toIndex) = + _MoveRootView; + const factory SidebarRootViewsEvent.didReceiveViews( + FlowyResult, FlowyError> appsOrFail, + ) = _ReceiveApps; +} + +@freezed +class SidebarRootViewState with _$SidebarRootViewState { + const factory SidebarRootViewState({ + required List views, + required FlowyResult successOrFailure, + @Default(null) ViewPB? lastCreatedRootView, + }) = _SidebarRootViewState; + + factory SidebarRootViewState.initial() => SidebarRootViewState( + views: [], + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 20d9d0ba41..8d33334c8d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -1,10 +1,13 @@ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; part 'user_workspace_bloc.freezed.dart'; @@ -33,43 +36,207 @@ class UserWorkspaceBloc extends Bloc { }, createWorkspace: (name, desc) async { final result = await _userService.createUserWorkspace(name); + final (workspaces, createWorkspaceResult) = result.fold( + (s) { + final workspaces = [...state.workspaces, s]; + return ( + workspaces, + FlowyResult.success(null) + ); + }, + (e) { + Log.error(e); + return (state.workspaces, FlowyResult.failure(e)); + }, + ); emit( state.copyWith( openWorkspaceResult: null, deleteWorkspaceResult: null, - createWorkspaceResult: - result.fold((s) => FlowyResult.success(null), (e) { - Log.error(e); - return FlowyResult.failure(e); - }), + updateWorkspaceIconResult: null, + createWorkspaceResult: createWorkspaceResult, + workspaces: workspaces, ), ); }, deleteWorkspace: (workspaceId) async { + if (state.workspaces.length <= 1) { + // do not allow to delete the last workspace + return emit( + state.copyWith( + openWorkspaceResult: null, + createWorkspaceResult: null, + updateWorkspaceIconResult: null, + renameWorkspaceResult: null, + deleteWorkspaceResult: FlowyResult.failure( + FlowyError( + code: ErrorCode.Internal, + msg: 'Cannot delete the last workspace', + ), + ), + ), + ); + } + final result = await _userService.deleteWorkspaceById(workspaceId); + final (workspaces, deleteWorkspaceResult) = result.fold( + (s) { + // if the current workspace is deleted, open the first workspace + if (state.currentWorkspace?.workspaceId == workspaceId) { + add(OpenWorkspace(state.workspaces.first.workspaceId)); + } + // remove the deleted workspace from the list instead of fetching + // the workspaces again + final workspaces = [...state.workspaces]..removeWhere( + (e) => e.workspaceId == workspaceId, + ); + return ( + workspaces, + FlowyResult.success(null) + ); + }, + (e) { + Log.error(e); + return (state.workspaces, FlowyResult.failure(e)); + }, + ); + emit( state.copyWith( openWorkspaceResult: null, createWorkspaceResult: null, - deleteWorkspaceResult: - result.fold((s) => FlowyResult.success(null), (e) { - Log.error(e); - return FlowyResult.failure(e); - }), + updateWorkspaceIconResult: null, + renameWorkspaceResult: null, + deleteWorkspaceResult: deleteWorkspaceResult, + workspaces: workspaces, ), ); }, openWorkspace: (workspaceId) async { - final result = await _userService.openWorkspace(workspaceId); + final (currentWorkspace, openWorkspaceResult) = + await _userService.openWorkspace(workspaceId).fold( + (s) { + final openedWorkspace = state.workspaces.firstWhere( + (e) => e.workspaceId == workspaceId, + ); + return ( + openedWorkspace, + FlowyResult.success(null) + ); + }, + (f) { + Log.error(f); + return (state.currentWorkspace, FlowyResult.failure(f)); + }, + ); + emit( state.copyWith( createWorkspaceResult: null, deleteWorkspaceResult: null, - openWorkspaceResult: - result.fold((s) => FlowyResult.success(null), (e) { - Log.error(e); - return FlowyResult.failure(e); - }), + updateWorkspaceIconResult: null, + openWorkspaceResult: openWorkspaceResult, + currentWorkspace: currentWorkspace, + ), + ); + }, + renameWorkspace: (workspaceId, name) async { + final result = await _userService.renameWorkspace( + workspaceId, + name, + ); + final (workspaces, currentWorkspace, renameWorkspaceResult) = + result.fold( + (s) { + final workspaces = state.workspaces.map((e) { + if (e.workspaceId == workspaceId) { + e.freeze(); + return e.rebuild((p0) { + p0.name = name; + }); + } + return e; + }).toList(); + + final currentWorkspace = workspaces.firstWhere( + (e) => e.workspaceId == state.currentWorkspace?.workspaceId, + ); + + return ( + workspaces, + currentWorkspace, + FlowyResult.success(null), + ); + }, + (e) { + Log.error(e); + return ( + state.workspaces, + state.currentWorkspace, + FlowyResult.failure(e), + ); + }, + ); + emit( + state.copyWith( + createWorkspaceResult: null, + deleteWorkspaceResult: null, + openWorkspaceResult: null, + updateWorkspaceIconResult: null, + workspaces: workspaces, + currentWorkspace: currentWorkspace, + renameWorkspaceResult: renameWorkspaceResult, + ), + ); + }, + updateWorkspaceIcon: (workspaceId, icon) async { + final result = await _userService.updateWorkspaceIcon( + workspaceId, + icon, + ); + + final (workspaces, currentWorkspace, updateWorkspaceIconResult) = + result.fold( + (s) { + final workspaces = state.workspaces.map((e) { + if (e.workspaceId == workspaceId) { + e.freeze(); + return e.rebuild((p0) { + // TODO(Lucas): the icon is not ready in the backend + }); + } + return e; + }).toList(); + + final currentWorkspace = workspaces.firstWhere( + (e) => e.workspaceId == state.currentWorkspace?.workspaceId, + ); + + return ( + workspaces, + currentWorkspace, + FlowyResult.success(null), + ); + }, + (e) { + Log.error(e); + return ( + state.workspaces, + state.currentWorkspace, + FlowyResult.failure(e), + ); + }, + ); + + emit( + state.copyWith( + createWorkspaceResult: null, + deleteWorkspaceResult: null, + openWorkspaceResult: null, + renameWorkspaceResult: null, + updateWorkspaceIconResult: updateWorkspaceIconResult, + workspaces: workspaces, + currentWorkspace: currentWorkspace, ), ); }, @@ -83,24 +250,17 @@ class UserWorkspaceBloc extends Bloc { Future<(UserWorkspacePB currentWorkspace, List workspaces)?> _fetchWorkspaces() async { - final result = await _userService.getCurrentWorkspace(); - return result.fold((currentWorkspace) async { - final result = await _userService.getWorkspaces(); - return result.fold((workspaces) { - return ( - workspaces.firstWhere( - (e) => e.workspaceId == currentWorkspace.id, - ), - workspaces - ); - }, (e) { - Log.error(e); - return null; - }); - }, (e) { + try { + final currentWorkspace = + await _userService.getCurrentWorkspace().getOrThrow(); + final workspaces = await _userService.getWorkspaces().getOrThrow(); + final currentWorkspaceInList = + workspaces.firstWhere((e) => e.workspaceId == currentWorkspace.id); + return (currentWorkspaceInList, workspaces); + } catch (e) { Log.error(e); return null; - }); + } } } @@ -114,6 +274,14 @@ class UserWorkspaceEvent with _$UserWorkspaceEvent { DeleteWorkspace; const factory UserWorkspaceEvent.openWorkspace(String workspaceId) = OpenWorkspace; + const factory UserWorkspaceEvent.renameWorkspace( + String workspaceId, + String name, + ) = _RenameWorkspace; + const factory UserWorkspaceEvent.updateWorkspaceIcon( + String workspaceId, + String icon, + ) = _UpdateWorkspaceIcon; const factory UserWorkspaceEvent.workspacesReceived( FlowyResult, FlowyError> workspacesOrFail, ) = WorkspacesReceived; @@ -127,8 +295,10 @@ class UserWorkspaceState with _$UserWorkspaceState { @Default(null) FlowyResult? createWorkspaceResult, @Default(null) FlowyResult? deleteWorkspaceResult, @Default(null) FlowyResult? openWorkspaceResult, + @Default(null) FlowyResult? renameWorkspaceResult, + @Default(null) FlowyResult? updateWorkspaceIconResult, }) = _UserWorkspaceState; - factory UserWorkspaceState.initial() => + factory UserWorkspaceState.initial() => const UserWorkspaceState(currentWorkspace: null, workspaces: []); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart index c8a44d2f75..ec86203599 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart'; @@ -125,8 +125,8 @@ class _PersonalFolderHeaderState extends State { LocaleKeys.newPageText.tr(), (viewName, _) { if (viewName.isNotEmpty) { - context.read().add( - MenuEvent.createApp( + context.read().add( + SidebarRootViewsEvent.createRootView( viewName, index: 0, ), 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 8a284464b8..e02a5b0c74 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,14 +1,13 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_action.dart'; import 'package:appflowy/workspace/application/notifications/notification_action_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'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; @@ -22,6 +21,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_editor/appflowy_editor.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. @@ -81,44 +81,72 @@ class _HomeSideBarState extends State { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => getIt(), - ), - BlocProvider( - create: (_) => MenuBloc( - user: widget.userProfile, - workspaceId: widget.workspaceSetting.workspaceId, - )..add(const MenuEvent.initial()), - ), - ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => - p.lastCreatedView?.id != c.lastCreatedView?.id, - listener: (context, state) => context.read().add( - TabsEvent.openPlugin(plugin: state.lastCreatedView!.plugin()), + return BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: widget.userProfile) + ..add(const UserWorkspaceEvent.fetchWorkspaces()), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + builder: (context, state) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => getIt(), + ), + BlocProvider( + create: (_) => SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + widget.userProfile, + state.currentWorkspace?.workspaceId ?? + widget.workspaceSetting.workspaceId, + ), + ), + ), + ], + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) => context.read().add( + TabsEvent.openPlugin( + plugin: state.lastCreatedRootView!.plugin(), + ), + ), ), - ), - BlocListener( - listenWhen: (_, curr) => curr.action != null, - listener: _onNotificationAction, - ), - ], - child: Builder( - builder: (context) { - final menuState = context.watch().state; - final favoriteState = context.watch().state; + BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: _onNotificationAction, + ), + BlocListener( + listener: (context, state) { + context.read().add( + SidebarRootViewsEvent.reset( + widget.userProfile, + state.currentWorkspace?.workspaceId ?? + widget.workspaceSetting.workspaceId, + ), + ); + }, + ), + ], + child: Builder( + builder: (context) { + final menuState = context.watch().state; + final favoriteState = context.watch().state; - return _buildSidebar( - context, - menuState.views, - favoriteState.views, - ); - }, - ), + return _buildSidebar( + context, + menuState.views, + favoriteState.views, + ); + }, + ), + ), + ); + }, ), ); } @@ -195,8 +223,11 @@ class _HomeSideBarState extends State { final action = state.action; if (action != null) { if (action.type == ActionType.openView) { - final view = - context.read().state.views.findView(action.objectId); + final view = context + .read() + .state + .views + .findView(action.objectId); if (view != null) { final Map arguments = {}; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart index 909124f70a..d5cd8a65ae 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; @@ -25,7 +25,9 @@ class SidebarNewPageButton extends StatelessWidget { LocaleKeys.newPageText.tr(), (viewName, _) { if (viewName.isNotEmpty) { - context.read().add(MenuEvent.createApp(viewName)); + context + .read() + .add(SidebarRootViewsEvent.createRootView(viewName)); } }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart index 1c7452584a..71c04cf048 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart @@ -4,7 +4,7 @@ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; @@ -24,7 +24,7 @@ class SidebarTopMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return SizedBox( height: HomeSizes.topBarHeight, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart index c6fe4b4d5a..1ac1afbabd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart @@ -3,7 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_item_list.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -13,6 +13,7 @@ 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/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarWorkspace extends StatelessWidget { @@ -27,32 +28,28 @@ class SidebarWorkspace extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => UserWorkspaceBloc(userProfile: userProfile) - ..add(const UserWorkspaceEvent.fetchWorkspaces()), - child: BlocConsumer( - listener: _showResultDialog, - builder: (context, state) { - final currentWorkspace = state.currentWorkspace; - // todo: show something if there is no workspace - if (currentWorkspace == null) { - return const SizedBox.shrink(); - } - return Row( - children: [ - Expanded( - child: _WorkspaceWrapper( - userProfile: userProfile, - currentWorkspace: currentWorkspace, - ), + return BlocConsumer( + listener: _showResultDialog, + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + // todo: show something if there is no workspace + if (currentWorkspace == null) { + return const SizedBox.shrink(); + } + return Row( + children: [ + Expanded( + child: _WorkspaceWrapper( + userProfile: userProfile, + currentWorkspace: currentWorkspace, ), - UserSettingButton(userProfile: userProfile), - const HSpace(4), - NotificationButton(views: views), - ], - ); - }, - ), + ), + UserSettingButton(userProfile: userProfile), + const HSpace(4), + NotificationButton(views: views), + ], + ); + }, ); } @@ -86,6 +83,26 @@ class SidebarWorkspace extends StatelessWidget { showSnackBarMessage(context, message); return; } + + result = state.updateWorkspaceIconResult; + if (result != null) { + final message = result.fold( + (s) => LocaleKeys.workspace_updateIconSuccess.tr(), + (e) => '${LocaleKeys.workspace_updateIconFailed.tr()}: ${e.msg}', + ); + showSnackBarMessage(context, message); + return; + } + + result = state.renameWorkspaceResult; + if (result != null) { + final message = result.fold( + (s) => LocaleKeys.workspace_renameSuccess.tr(), + (e) => '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}', + ); + showSnackBarMessage(context, message); + return; + } } } @@ -161,6 +178,7 @@ class _DesktopWorkspaceWrapperState extends State<_DesktopWorkspaceWrapper> { }, child: FlowyButton( onTap: () => controller.show(), + useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(vertical: 8), text: Row( children: [ @@ -170,9 +188,11 @@ class _DesktopWorkspaceWrapperState extends State<_DesktopWorkspaceWrapper> { child: WorkspaceIcon(workspace: widget.currentWorkspace), ), const HSpace(8), - FlowyText.medium( - widget.currentWorkspace.name, - overflow: TextOverflow.ellipsis, + Expanded( + child: FlowyText.medium( + widget.currentWorkspace.name, + overflow: TextOverflow.ellipsis, + ), ), const FlowySvg(FlowySvgs.drop_menu_show_m), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart index 84dd7abe52..7fa07bcfe5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart @@ -64,23 +64,34 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { ), margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), onTap: () async { + PopoverContainer.of(context).closeAll(); + + final workspaceBloc = context.read(); switch (inner) { case WorkspaceMoreAction.delete: await NavigatorAlertDialog( title: LocaleKeys.workspace_deleteWorkspaceHintText.tr(), confirm: () { - context.read().add( - UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId), - ); + workspaceBloc.add( + UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId), + ); }, ).show(context); case WorkspaceMoreAction.rename: - - // TODO(Lucas): integrate with the backend - } - - if (context.mounted) { - PopoverContainer.of(context).closeAll(); + await NavigatorTextFieldDialog( + title: LocaleKeys.workspace_create.tr(), + value: workspace.name, + hintText: '', + autoSelectAllText: true, + onConfirm: (name, context) async { + workspaceBloc.add( + UserWorkspaceEvent.renameWorkspace( + workspace.workspaceId, + name, + ), + ); + }, + ).show(context); } }, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart index fdf7935482..93d60414bd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart @@ -1,7 +1,11 @@ +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class WorkspaceIcon extends StatelessWidget { const WorkspaceIcon({ @@ -13,17 +17,37 @@ class WorkspaceIcon extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO(Lucas): support icon later - return Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: ColorGenerator.generateColorFromString(workspace.name), - borderRadius: BorderRadius.circular(4), - ), - child: FlowyText( - workspace.name.isEmpty ? '' : workspace.name.substring(0, 1), - fontSize: 16, - color: Colors.black, + return AppFlowyPopover( + offset: const Offset(0, 8), + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(360, 380)), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (BuildContext popoverContext) { + return FlowyIconPicker( + onSelected: (result) { + context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + workspace.workspaceId, + result.emoji, + ), + ); + }, + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorGenerator.generateColorFromString(workspace.name), + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText( + workspace.name.isEmpty ? '' : workspace.name.substring(0, 1), + fontSize: 16, + color: Colors.black, + ), + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_item_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart similarity index 77% rename from frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_item_list.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 359933970a..c02b2ad22e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_item_list.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -4,10 +4,8 @@ import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -15,6 +13,9 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +@visibleForTesting +const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); + class WorkspacesMenu extends StatelessWidget { const WorkspacesMenu({ super.key, @@ -38,14 +39,17 @@ class WorkspacesMenu extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), child: Row( children: [ - FlowyText.medium( - _getUserInfo(), - fontSize: 12.0, - overflow: TextOverflow.ellipsis, - color: Theme.of(context).hintColor, + Expanded( + child: FlowyText.medium( + _getUserInfo(), + fontSize: 12.0, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), ), - const Spacer(), + const HSpace(4.0), FlowyButton( + key: createWorkspaceButtonKey, useIntrinsicWidth: true, text: const FlowySvg(FlowySvgs.add_m), onTap: () { @@ -57,7 +61,7 @@ class WorkspacesMenu extends StatelessWidget { ), ), for (final workspace in workspaces) ...[ - _WorkspaceMenuItem( + WorkspaceMenuItem( workspace: workspace, userProfile: userProfile, isSelected: workspace.workspaceId == currentWorkspace.workspaceId, @@ -82,29 +86,19 @@ class WorkspacesMenu extends StatelessWidget { Future _showCreateWorkspaceDialog(BuildContext context) async { if (context.mounted) { - await NavigatorTextFieldDialog( - title: LocaleKeys.workspace_create.tr(), - value: '', - hintText: '', - autoSelectAllText: true, - onConfirm: (name, context) async { - final request = CreateWorkspacePB.create()..name = name; - final result = await UserEventCreateWorkspace(request).send(); - final message = result.fold( - (s) => LocaleKeys.workspace_createSuccess.tr(), - (e) => '${LocaleKeys.workspace_createFailed.tr()}: ${e.msg}', - ); - if (context.mounted) { - showSnackBarMessage(context, message); - } + final workspaceBloc = context.read(); + await CreateWorkspaceDialog( + onConfirm: (name) { + workspaceBloc.add(UserWorkspaceEvent.createWorkspace(name, '')); }, ).show(context); } } } -class _WorkspaceMenuItem extends StatelessWidget { - const _WorkspaceMenuItem({ +class WorkspaceMenuItem extends StatelessWidget { + const WorkspaceMenuItem({ + super.key, required this.workspace, required this.userProfile, required this.isSelected, @@ -143,9 +137,10 @@ class _WorkspaceMenuItem extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), iconPadding: 10.0, leftIconSize: const Size.square(32), - leftIcon: WorkspaceIcon( - workspace: workspace, + leftIcon: const SizedBox.square( + dimension: 32, ), + rightIcon: const HSpace(42.0), text: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -163,6 +158,15 @@ class _WorkspaceMenuItem extends StatelessWidget { ], ), ), + Positioned( + left: 12, + child: SizedBox.square( + dimension: 32, + child: WorkspaceIcon( + workspace: workspace, + ), + ), + ), Positioned( right: 12.0, child: Align(child: _buildRightIcon(context)), @@ -187,9 +191,28 @@ class _WorkspaceMenuItem extends StatelessWidget { WorkspaceMoreActionList(workspace: workspace), const FlowySvg( FlowySvgs.blue_check_s, - blendMode: null, ), ], ); } } + +class CreateWorkspaceDialog extends StatelessWidget { + const CreateWorkspaceDialog({ + super.key, + required this.onConfirm, + }); + + final void Function(String name) onConfirm; + + @override + Widget build(BuildContext context) { + return NavigatorTextFieldDialog( + title: LocaleKeys.workspace_create.tr(), + value: '', + hintText: '', + autoSelectAllText: true, + onConfirm: (name, _) => onConfirm(name), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart index 1f19ffaa41..97b81cfe1a 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart @@ -1,3 +1,4 @@ library appflowy_result; +export 'src/async_result.dart'; export 'src/result.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart new file mode 100644 index 0000000000..328aa03556 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart @@ -0,0 +1,33 @@ +import 'package:appflowy_result/appflowy_result.dart'; + +typedef FlowyAsyncResult = Future>; + +extension FlowyAsyncResultExtension + on FlowyAsyncResult { + Future getOrElse(S Function(F f) onFailure) { + return then((result) => result.getOrElse(onFailure)); + } + + Future getOrThrow() { + return then((result) => result.getOrThrow()); + } + + Future fold( + W Function(S s) onSuccess, + W Function(F f) onFailure, + ) { + return then((result) => result.fold(onSuccess, onFailure)); + } + + Future isError() { + return then((result) => result.isFailure()); + } + + Future isSuccess() { + return then((result) => result.isSuccess()); + } + + FlowyAsyncResult onFailure(void Function(F failure) onFailure) { + return then((result) => result..onFailure(onFailure)); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart index 88d3051332..dbffef42c7 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart @@ -1,30 +1,28 @@ -abstract class FlowyResult { +abstract class FlowyResult { const FlowyResult(); factory FlowyResult.success(S s) => FlowySuccess(s); - factory FlowyResult.failure(F e) => FlowyFailure(e); + factory FlowyResult.failure(F f) => FlowyFailure(f); - T fold(T Function(S s) onSuccess, T Function(F e) onFailure); + T fold(T Function(S s) onSuccess, T Function(F f) onFailure); FlowyResult map(T Function(S success) fn); - FlowyResult mapError(T Function(F error) fn); + FlowyResult mapError(T Function(F failure) fn); bool isSuccess(); bool isFailure(); S? toNullable(); - void onSuccess( - void Function(S s) onSuccess, - ); + void onSuccess(void Function(S s) onSuccess); + void onFailure(void Function(F f) onFailure); - void onFailure( - void Function(F f) onFailure, - ); + S getOrElse(S Function(F failure) onFailure); + S getOrThrow(); } -class FlowySuccess implements FlowyResult { +class FlowySuccess implements FlowyResult { final S _value; FlowySuccess(this._value); @@ -54,7 +52,7 @@ class FlowySuccess implements FlowyResult { } @override - FlowyResult mapError(T Function(F error) fn) { + FlowyResult mapError(T Function(F error) fn) { return FlowySuccess(_value); } @@ -80,40 +78,50 @@ class FlowySuccess implements FlowyResult { @override void onFailure(void Function(F failure) onFailure) {} + + @override + S getOrElse(S Function(F failure) onFailure) { + return _value; + } + + @override + S getOrThrow() { + return _value; + } } -class FlowyFailure implements FlowyResult { - final F _error; +class FlowyFailure implements FlowyResult { + final F _value; - FlowyFailure(this._error); + FlowyFailure(this._value); - F get error => _error; + F get error => _value; @override bool operator ==(Object other) => identical(this, other) || other is FlowyFailure && runtimeType == other.runtimeType && - _error == other._error; + _value == other._value; @override - int get hashCode => _error.hashCode; + int get hashCode => _value.hashCode; @override - String toString() => 'Failure(error: $_error)'; + String toString() => 'Failure(error: $_value)'; @override T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => - onFailure(_error); + onFailure(_value); @override map(T Function(S success) fn) { - return FlowyFailure(_error); + return FlowyFailure(_value); } @override - FlowyResult mapError(T Function(F error) fn) { - return FlowyFailure(fn(_error)); + FlowyResult mapError(T Function(F error) fn) { + return FlowyFailure(fn(_value)); } @override @@ -136,6 +144,16 @@ class FlowyFailure implements FlowyResult { @override void onFailure(void Function(F failure) onFailure) { - onFailure(_error); + onFailure(_value); + } + + @override + S getOrElse(S Function(F failure) onFailure) { + return onFailure(_value); + } + + @override + S getOrThrow() { + throw _value; } } diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart index cae6493ed4..7c2e115524 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; @@ -10,26 +10,33 @@ void main() { }); test('assert initial apps is the build-in app', () async { - final menuBloc = MenuBloc( - user: testContext.userProfile, - workspaceId: testContext.currentWorkspace.id, - )..add(const MenuEvent.initial()); + final menuBloc = SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + testContext.userProfile, + testContext.currentWorkspace.id, + ), + ); + await blocResponseFuture(); assert(menuBloc.state.views.length == 1); }); test('reorder apps', () async { - final menuBloc = MenuBloc( - user: testContext.userProfile, - workspaceId: testContext.currentWorkspace.id, - )..add(const MenuEvent.initial()); + final menuBloc = SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + testContext.userProfile, + testContext.currentWorkspace.id, + ), + ); await blocResponseFuture(); - menuBloc.add(const MenuEvent.createApp("App 1")); + menuBloc.add(const SidebarRootViewsEvent.createRootView("App 1")); await blocResponseFuture(); - menuBloc.add(const MenuEvent.createApp("App 2")); + menuBloc.add(const SidebarRootViewsEvent.createRootView("App 2")); await blocResponseFuture(); - menuBloc.add(const MenuEvent.createApp("App 3")); + menuBloc.add(const SidebarRootViewsEvent.createRootView("App 3")); await blocResponseFuture(); assert(menuBloc.state.views[1].name == 'App 1'); diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 8b8a9bd3a2..b4badec242 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -71,7 +71,11 @@ "deleteSuccess": "Workspace deleted successfully", "deleteFailed": "Failed to delete workspace", "openSuccess": "Open workspace successfully", - "openFailed": "Failed to open workspace" + "openFailed": "Failed to open workspace", + "renameSuccess": "Workspace renamed successfully", + "renameFailed": "Failed to rename workspace", + "updateIconSuccess": "Workspace reset successfully", + "updateIconFailed": "Failed to reset workspace" }, "shareAction": { "buttonText": "Share",