diff --git a/frontend/appflowy_flutter/assets/images/home/Favorite/active.svg b/frontend/appflowy_flutter/assets/images/home/Favorite/active.svg deleted file mode 100644 index 0f7ba51f4d..0000000000 --- a/frontend/appflowy_flutter/assets/images/home/Favorite/active.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_flutter/assets/images/home/Favorite/inactive.svg b/frontend/appflowy_flutter/assets/images/home/Favorite/inactive.svg deleted file mode 100644 index a6e84e8e91..0000000000 --- a/frontend/appflowy_flutter/assets/images/home/Favorite/inactive.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/appflowy_flutter/assets/images/home/favorite.svg b/frontend/appflowy_flutter/assets/images/home/favorite.svg index 3d862a56f9..8ad54bbbb5 100644 --- a/frontend/appflowy_flutter/assets/images/home/favorite.svg +++ b/frontend/appflowy_flutter/assets/images/home/favorite.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/appflowy_flutter/assets/images/home/unfavorite.svg b/frontend/appflowy_flutter/assets/images/home/unfavorite.svg new file mode 100644 index 0000000000..b8350ff917 --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/home/unfavorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart new file mode 100644 index 0000000000..76d183d625 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('sidebar expand test', () { + bool isExpanded({required FolderCategoryType type}) { + if (type == FolderCategoryType.personal) { + return find + .descendant( + of: find.byType(PersonalFolder), + matching: find.byType(ViewItem), + ) + .evaluate() + .isNotEmpty; + } + return false; + } + + testWidgets('first time the personal folder is expanded', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // first time is expanded + expect(isExpanded(type: FolderCategoryType.personal), true); + + // collapse the personal folder + await tester.tapButton( + find.byTooltip(LocaleKeys.sideBar_clickToHidePersonal.tr()), + ); + expect(isExpanded(type: FolderCategoryType.personal), false); + + // expand the personal folder + await tester.tapButton( + find.byTooltip(LocaleKeys.sideBar_clickToHidePersonal.tr()), + ); + expect(isExpanded(type: FolderCategoryType.personal), true); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart new file mode 100644 index 0000000000..fd531d0e4f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart @@ -0,0 +1,175 @@ +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/base.dart'; +import '../util/common_operations.dart'; +import '../util/expectation.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Favorites', () { + testWidgets( + 'Toggle favorites for views creates / removes the favorite header along with favorite views', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // no favorite folder + expect(find.byType(FavoriteFolder), findsNothing); + + // create the nested views + final names = [ + 1, + 2, + ].map((e) => 'document_$e').toList(); + for (var i = 0; i < names.length; i++) { + final parentName = i == 0 ? gettingStated : names[i - 1]; + await tester.createNewPageWithName( + name: names[i], + parentName: parentName, + layout: ViewLayoutPB.Document, + ); + tester.expectToSeePageName( + names[i], + parentName: parentName, + layout: ViewLayoutPB.Document, + parentLayout: ViewLayoutPB.Document, + ); + } + + await tester.favoriteViewByName(gettingStated); + expect( + tester.findFavoritePageName(gettingStated), + findsOneWidget, + ); + + await tester.favoriteViewByName(names[1]); + expect( + tester.findFavoritePageName(names[1]), + findsNWidgets(2), + ); + + await tester.unfavoriteViewByName(gettingStated); + expect( + tester.findFavoritePageName(gettingStated), + findsNothing, + ); + expect( + tester.findFavoritePageName( + names[1], + ), + findsOneWidget, + ); + + await tester.unfavoriteViewByName(names[1]); + expect( + tester.findFavoritePageName( + names[1], + ), + findsNothing, + ); + }); + + testWidgets( + 'renaming a favorite view updates name under favorite header', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + const name = 'test'; + await tester.favoriteViewByName(gettingStated); + await tester.hoverOnPageName( + gettingStated, + layout: ViewLayoutPB.Document, + onHover: () async { + await tester.renamePage(name); + await tester.pumpAndSettle(); + }, + ); + expect( + tester.findPageName(name), + findsNWidgets(2), + ); + expect( + tester.findFavoritePageName(name), + findsNothing, + ); + }, + ); + + testWidgets( + 'deleting first level favorite view removes its instance from favorite header, deleting root level views leads to removal of all favorites that are its children', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + final names = [1, 2].map((e) => 'document_$e').toList(); + for (var i = 0; i < names.length; i++) { + final parentName = i == 0 ? gettingStated : names[i - 1]; + await tester.createNewPageWithName( + name: names[i], + parentName: parentName, + layout: ViewLayoutPB.Document, + ); + tester.expectToSeePageName(names[i], parentName: parentName); + } + await tester.favoriteViewByName(gettingStated); + await tester.favoriteViewByName(names[0]); + await tester.favoriteViewByName(names[1]); + + expect( + find.byWidgetPredicate( + (widget) => + widget is ViewItem && + widget.view.isFavorite && + widget.categoryType == FolderCategoryType.favorite, + ), + findsNWidgets(6), + ); + + await tester.hoverOnPageName( + names[1], + layout: ViewLayoutPB.Document, + onHover: () async { + await tester.tapDeletePageButton(); + await tester.pumpAndSettle(); + }, + ); + + expect( + find.byWidgetPredicate( + (widget) => + widget is ViewItem && + widget.view.isFavorite && + widget.categoryType == FolderCategoryType.favorite, + ), + findsNWidgets(3), + ); + + await tester.hoverOnPageName( + gettingStated, + layout: ViewLayoutPB.Document, + onHover: () async { + await tester.tapDeletePageButton(); + await tester.pumpAndSettle(); + }, + ); + + expect( + find.byWidgetPredicate( + (widget) => + widget is ViewItem && + widget.view.isFavorite && + widget.categoryType == FolderCategoryType.favorite, + ), + findsNothing, + ); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart index 6e7e13337b..f4d54a2160 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart @@ -1,10 +1,14 @@ import 'package:integration_test/integration_test.dart'; import 'sidebar_test.dart' as sidebar_test; +import 'sidebar_expand_test.dart' as sidebar_expanded_test; +import 'sidebar_favorites_test.dart' as sidebar_favorite_test; void startTesting() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Sidebar integration tests sidebar_test.main(); + sidebar_expanded_test.main(); + sidebar_favorite_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index a0b6ace78b..5481bdd791 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -1,4 +1,3 @@ -import 'dart:ui'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.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'; @@ -14,6 +13,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_langua import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -199,6 +199,18 @@ extension CommonOperations on WidgetTester { await tapButtonWithName(ViewMoreActionType.rename.name); } + /// Tap the favorite page button + Future tapFavoritePageButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.favorite.name); + } + + /// Tap the unfavorite page button + Future tapUnfavoritePageButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.unFavorite.name); + } + /// Rename the page. Future renamePage(String name) async { await tapRenamePageButton(); @@ -332,6 +344,36 @@ extension CommonOperations on WidgetTester { await pumpAndSettle(); } + Future favoriteViewByName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + await hoverOnPageName( + name, + layout: layout, + useLast: false, + onHover: () async { + await tapFavoritePageButton(); + await pumpAndSettle(); + }, + ); + } + + Future unfavoriteViewByName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + await hoverOnPageName( + name, + layout: layout, + useLast: false, + onHover: () async { + await tapUnfavoritePageButton(); + await pumpAndSettle(); + }, + ); + } + Future movePageToOtherPage({ required String name, required String parentName, diff --git a/frontend/appflowy_flutter/integration_test/util/expectation.dart b/frontend/appflowy_flutter/integration_test/util/expectation.dart index a126410ffc..fb5c528663 100644 --- a/frontend/appflowy_flutter/integration_test/util/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/util/expectation.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; @@ -147,7 +148,24 @@ extension Expectation on WidgetTester { expect(textWidget, findsOneWidget); } - /// Find the page name on the home page. + /// Find if the page is favorite + Finder findFavoritePageName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + String? parentName, + ViewLayoutPB parentLayout = ViewLayoutPB.Document, + }) { + return find.byWidgetPredicate( + (widget) => + widget is ViewItem && + widget.view.isFavorite && + widget.categoryType == FolderCategoryType.favorite && + widget.view.name == name && + widget.view.layout == layout, + skipOffstage: false, + ); + } + Finder findPageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, @@ -168,11 +186,11 @@ extension Expectation on WidgetTester { of: find.byWidgetPredicate( (widget) => widget is ViewItem && - widget.view.name == name && - widget.view.layout == layout, + widget.view.name == parentName && + widget.view.layout == parentLayout, skipOffstage: false, ), - matching: findPageName(name), + matching: findPageName(name, layout: layout), ); } } diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 032400140c..b5cbd8baf8 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -35,4 +35,10 @@ class KVKeys { /// The value is a json string with the following format: /// {'viewId': true, 'viewId2': false} static const String expandedViews = 'expandedViews'; + + /// The key for saving the expanded folder + /// + /// The value is a json string with the following format: + /// {'SidebarFolderCategoryType.value': true} + static const String expandedFolders = 'expandedFolders'; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart index 5377c6477c..abae17604c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart @@ -15,7 +15,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; @@ -155,11 +154,10 @@ class _BuiltInPageWidgetState extends State { onSelected: (action, controller) async { switch (action.inner) { case _ActionType.viewDatabase: - getIt().latestOpenView = viewPB; - getIt().add( TabsEvent.openPlugin( plugin: viewPB.plugin(), + view: viewPB, ), ); break; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index 2411fde431..24ab28c421 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -4,7 +4,6 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' @@ -109,10 +108,10 @@ class _MentionPageBlockState extends State { Log.error('Page($pageId) not found'); return; } - getIt().latestOpenView = view; getIt().add( TabsEvent.openPlugin( plugin: view.plugin(), + view: view, ), ); } diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index ede07fd44e..64d0d55c88 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -15,6 +15,7 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; @@ -156,6 +157,7 @@ void _resolveFolderDeps(GetIt getIt) { getIt.registerFactory( () => TrashBloc(), ); + getIt.registerFactory(() => FavoriteBloc()); } void _resolveDocDeps(GetIt getIt) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart new file mode 100644 index 0000000000..5c7227abf4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'favorite_listener.dart'; + +part 'favorite_bloc.freezed.dart'; + +class FavoriteBloc extends Bloc { + final _service = FavoriteService(); + final _listener = FavoriteListener(); + + FavoriteBloc() : super(FavoriteState.initial()) { + on( + (event, emit) async { + await event.map( + initial: (e) async { + _listener.start( + favoritesUpdated: _onFavoritesUpdated, + ); + final result = await _service.readFavorites(); + emit( + result.fold( + (view) => state.copyWith( + views: view.items, + ), + (error) => state.copyWith( + views: [], + ), + ), + ); + }, + didFavorite: (e) { + emit( + state.copyWith(views: [...state.views, ...e.favorite.items]), + ); + }, + didUnfavorite: (e) { + final views = [...state.views]..removeWhere( + (view) => e.favorite.items.any((item) => item.id == view.id), + ); + emit( + state.copyWith(views: views), + ); + }, + toggle: (e) async { + await _service.toggleFavorite( + e.view.id, + !e.view.isFavorite, + ); + }, + ); + }, + ); + } + + void _onFavoritesUpdated( + Either favoriteOrFailed, + bool didFavorite, + ) { + favoriteOrFailed.fold( + (error) => Log.error(error), + (favorite) => didFavorite + ? add(FavoriteEvent.didFavorite(favorite)) + : add(FavoriteEvent.didUnfavorite(favorite)), + ); + } +} + +@freezed +class FavoriteEvent with _$FavoriteEvent { + const factory FavoriteEvent.initial() = Initial; + const factory FavoriteEvent.didFavorite(RepeatedViewPB favorite) = + DidFavorite; + const factory FavoriteEvent.didUnfavorite(RepeatedViewPB favorite) = + DidUnfavorite; + const factory FavoriteEvent.toggle(ViewPB view) = ToggleFavorite; +} + +@freezed +class FavoriteState with _$FavoriteState { + const factory FavoriteState({ + required List views, + }) = _FavoriteState; + + factory FavoriteState.initial() => const FavoriteState( + views: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart new file mode 100644 index 0000000000..c04ddb7a71 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/foundation.dart'; + +typedef FavoriteUpdated = void Function( + Either result, + bool isFavorite, +); + +class FavoriteListener { + StreamSubscription? _streamSubscription; + FolderNotificationParser? _parser; + + FavoriteUpdated? _favoriteUpdated; + + void start({ + FavoriteUpdated? favoritesUpdated, + }) { + _favoriteUpdated = favoritesUpdated; + _parser = FolderNotificationParser( + id: 'favorite', + callback: _observableCallback, + ); + _streamSubscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + void _observableCallback( + FolderNotification ty, + Either result, + ) { + if (_favoriteUpdated == null) { + return; + } + + final isFavorite = ty == FolderNotification.DidFavoriteView; + result.fold( + (payload) { + final view = RepeatedViewPB.fromBuffer(payload); + _favoriteUpdated!( + right(view), + isFavorite, + ); + }, + (error) => _favoriteUpdated!( + left(error), + isFavorite, + ), + ); + } + + Future stop() async { + _parser = null; + await _streamSubscription?.cancel(); + _favoriteUpdated = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart new file mode 100644 index 0000000000..c43b3f5ebb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; +import 'package:dartz/dartz.dart'; + +class FavoriteService { + Future> readFavorites() { + return FolderEventReadFavorites().send(); + } + + Future> toggleFavorite( + String viewId, + bool favoriteStatus, + ) async { + final id = RepeatedViewIdPB.create()..items.add(viewId); + return FolderEventToggleFavorite(id).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart new file mode 100644 index 0000000000..15a3302d43 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart @@ -0,0 +1,3 @@ +export 'favorite_bloc.dart'; +export 'favorite_listener.dart'; +export 'favorite_service.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart index c0f7cec91b..1764c3a911 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart @@ -41,7 +41,8 @@ class MenuBloc extends Bloc { createApp: (_CreateApp event) async { final result = await _workspaceService.createApp( name: event.name, - desc: event.desc ?? "", + desc: event.desc, + index: 0, // default to the first index ); result.fold( (app) => emit(state.copyWith(plugin: app.plugin())), diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart new file mode 100644 index 0000000000..95a888cfa3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart @@ -0,0 +1,83 @@ +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:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'folder_bloc.freezed.dart'; + +enum FolderCategoryType { + favorite, + personal, +} + +class FolderBloc extends Bloc { + FolderBloc({ + required FolderCategoryType type, + }) : super(FolderState.initial(type)) { + on((event, emit) async { + await event.map( + initial: (e) async { + // fetch the expand status + final isExpanded = await _getFolderExpandStatus(); + emit(state.copyWith(isExpanded: isExpanded)); + }, + expandOrUnExpand: (e) async { + final isExpanded = e.isExpanded ?? !state.isExpanded; + await _setFolderExpandStatus(e.isExpanded ?? !state.isExpanded); + emit(state.copyWith(isExpanded: isExpanded)); + }, + ); + }); + } + + Future _setFolderExpandStatus(bool isExpanded) async { + final result = await getIt().get(KVKeys.expandedViews); + final map = result.fold( + (l) => {}, + (r) => jsonDecode(r), + ); + if (isExpanded) { + // set expand status to true if it's not expanded + map[state.type.name] = true; + } else { + // remove the expand status if it's expanded + map.remove(state.type.name); + } + await getIt().set(KVKeys.expandedViews, jsonEncode(map)); + } + + Future _getFolderExpandStatus() async { + return getIt().get(KVKeys.expandedViews).then((result) { + return result.fold((l) => true, (r) { + final map = jsonDecode(r); + return map[state.type.name] ?? true; + }); + }); + } +} + +@freezed +class FolderEvent with _$FolderEvent { + const factory FolderEvent.initial() = Initial; + const factory FolderEvent.expandOrUnExpand({ + bool? isExpanded, + }) = ExpandOrUnExpand; +} + +@freezed +class FolderState with _$FolderState { + const factory FolderState({ + required FolderCategoryType type, + required bool isExpanded, + }) = _FolderState; + + factory FolderState.initial( + FolderCategoryType type, + ) => + FolderState( + type: type, + isExpanded: true, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart index 12983016d2..a2a4e1b536 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -34,6 +34,10 @@ class ViewBackendService { /// the database id. For example: "database_id": "xxx" /// Map ext = const {}, + + /// The [index] is the index of the view in the parent view. + /// If the index is null, the view will be added to the end of the list. + int? index, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = parentViewId @@ -47,6 +51,14 @@ class ViewBackendService { payload.meta.addAll(ext); } + if (desc != null) { + payload.desc = desc; + } + + if (index != null) { + payload.index = index; + } + return FolderEventCreateView(payload).send(); } @@ -118,11 +130,17 @@ class ViewBackendService { return FolderEventDuplicateView(view).send(); } + static Future> favorite({required String viewId}) { + final request = RepeatedViewIdPB.create()..items.add(viewId); + return FolderEventToggleFavorite(request).send(); + } + static Future> updateView({ required String viewId, String? name, String? iconURL, String? coverURL, + bool? isFavorite, }) { final payload = UpdateViewPayloadPB.create()..viewId = viewId; @@ -138,6 +156,9 @@ class ViewBackendService { payload.coverUrl = coverURL; } + if (isFavorite != null) { + payload.isFavorite = isFavorite; + } return FolderEventUpdateView(payload).send(); } 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 8fe3cff50f..4883657aff 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -15,16 +15,25 @@ class WorkspaceService { WorkspaceService({ required this.workspaceId, }); + Future> createApp({ required String name, String? desc, + int? index, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = workspaceId ..name = name - ..desc = desc ?? "" ..layout = ViewLayoutPB.Document; + if (desc != null) { + payload.desc = desc; + } + + if (index != null) { + payload.index = index; + } + return FolderEventCreateView(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart index 85da0cf414..8c33303b96 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart @@ -1,4 +1,5 @@ import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_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'; @@ -92,6 +93,7 @@ class ViewSectionItem extends StatelessWidget { if (onHover || state.isEditing) { children.add( ViewDisclosureButton( + state: state, onEdit: (isEdit) => blocContext.read().add(ViewEvent.setIsEditing(isEdit)), onAction: (action) { @@ -115,6 +117,11 @@ class ViewSectionItem extends StatelessWidget { case ViewDisclosureAction.duplicate: blocContext.read().add(const ViewEvent.duplicate()); break; + case ViewDisclosureAction.favorite: + blocContext + .read() + .add(FavoriteEvent.toggle(view)); + break; case ViewDisclosureAction.openInNewTab: blocContext.read().add( TabsEvent.openTab( @@ -143,11 +150,12 @@ enum ViewDisclosureAction { rename, delete, duplicate, + favorite, openInNewTab, } extension ViewDisclosureExtension on ViewDisclosureAction { - String get name { + String name({ViewState? state}) { switch (this) { case ViewDisclosureAction.rename: return LocaleKeys.disclosureAction_rename.tr(); @@ -155,12 +163,16 @@ extension ViewDisclosureExtension on ViewDisclosureAction { return LocaleKeys.disclosureAction_delete.tr(); case ViewDisclosureAction.duplicate: return LocaleKeys.disclosureAction_duplicate.tr(); + case ViewDisclosureAction.favorite: + return state!.view.isFavorite + ? LocaleKeys.disclosureAction_unfavorite.tr() + : LocaleKeys.disclosureAction_favorite.tr(); case ViewDisclosureAction.openInNewTab: return LocaleKeys.disclosureAction_openNewTab.tr(); } } - Widget icon(Color iconColor) { + Widget icon(Color iconColor, {ViewState? state}) { switch (this) { case ViewDisclosureAction.rename: return const FlowySvg(name: 'editor/edit'); @@ -168,6 +180,10 @@ extension ViewDisclosureExtension on ViewDisclosureAction { return const FlowySvg(name: 'editor/delete'); case ViewDisclosureAction.duplicate: return const FlowySvg(name: 'editor/copy'); + case ViewDisclosureAction.favorite: + return state!.view.isFavorite + ? const FlowySvg(name: 'home/favorite') + : const FlowySvg(name: 'home/unfavorite'); case ViewDisclosureAction.openInNewTab: return const FlowySvg(name: 'grid/expander'); } @@ -177,9 +193,11 @@ extension ViewDisclosureExtension on ViewDisclosureAction { class ViewDisclosureButton extends StatelessWidget { final Function(bool) onEdit; final Function(ViewDisclosureAction) onAction; + final ViewState state; const ViewDisclosureButton({ required this.onEdit, required this.onAction, + required this.state, Key? key, }) : super(key: key); @@ -188,7 +206,7 @@ class ViewDisclosureButton extends StatelessWidget { return PopoverActionList( direction: PopoverDirection.bottomWithCenterAligned, actions: ViewDisclosureAction.values - .map((action) => ViewDisclosureActionWrapper(action)) + .map((action) => ViewDisclosureActionWrapper(action, state)) .toList(), buildChild: (controller) { return FlowyIconButton( @@ -219,11 +237,12 @@ class ViewDisclosureButton extends StatelessWidget { class ViewDisclosureActionWrapper extends ActionCell { final ViewDisclosureAction inner; + final ViewState? state; - ViewDisclosureActionWrapper(this.inner); + ViewDisclosureActionWrapper(this.inner, [this.state]); @override - Widget? leftIcon(Color iconColor) => inner.icon(iconColor); + Widget? leftIcon(Color iconColor) => inner.icon(iconColor, state: state); @override - String get name => inner.name; + String get name => inner.name(state: state); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/favorite.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/favorite.dart deleted file mode 100644 index 8b13789179..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/favorite.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/header.dart deleted file mode 100644 index 5907027044..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/header.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class FavoriteHeader extends StatelessWidget { - const FavoriteHeader({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - throw UnimplementedError(); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/section.dart deleted file mode 100644 index 8b13789179..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/favorite/section.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart index 86ee7a575d..ffed3f0d05 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart @@ -4,6 +4,7 @@ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/trash/menu.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.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/tabs/tabs_bloc.dart'; @@ -27,6 +28,7 @@ import 'package:styled_widget/styled_widget.dart'; import '../navigation.dart'; import 'app/create_button.dart'; import 'app/menu_app.dart'; +import 'app/section/item.dart'; import 'menu_user.dart'; export './app/header/header.dart'; @@ -56,6 +58,10 @@ class HomeMenu extends StatelessWidget { return menuBloc; }, ), + BlocProvider( + create: (ctx) => + getIt()..add(const FavoriteEvent.initial()), + ) ], child: MultiBlocListener( listeners: [ @@ -105,6 +111,50 @@ class HomeMenu extends StatelessWidget { ); } + Widget _renderFavorites(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.views.isNotEmpty + ? ExpandableTheme( + data: ExpandableThemeData( + useInkWell: true, + animationDuration: Durations.medium, + ), + child: ExpandablePanel( + theme: const ExpandableThemeData( + headerAlignment: ExpandablePanelHeaderAlignment.center, + tapBodyToExpand: false, + tapBodyToCollapse: false, + tapHeaderToExpand: false, + iconPadding: EdgeInsets.zero, + hasIcon: false, + ), + // header: const FavoriteHeader(), + expanded: ScrollConfiguration( + behavior: + const ScrollBehavior().copyWith(scrollbars: false), + child: Column( + children: state.views + .map( + (e) => ViewSectionItem( + key: ValueKey(e.id), + isSelected: false, + onSelected: (view) => getIt() + .latestOpenView = view, + view: e, + ), + ) + .toList(), + ), + ), + collapsed: const SizedBox.shrink(), + ), + ) + : const SizedBox.shrink(); + }, + ); + } + Widget _renderApps(BuildContext context) { return ExpandableTheme( data: ExpandableThemeData( @@ -122,10 +172,16 @@ class HomeMenu extends StatelessWidget { return ReorderableListView.builder( itemCount: menuItems.length, buildDefaultDragHandles: false, - header: Padding( - padding: - EdgeInsets.only(bottom: 20.0 - MenuAppSizes.appVPadding), - child: MenuUser(user), + header: Column( + children: [ + Padding( + padding: EdgeInsets.only( + bottom: MenuAppSizes.appVPadding, + ), + child: MenuUser(user), + ), + _renderFavorites(context), + ], ), onReorder: (oldIndex, newIndex) { // Moving item1 from index 0 to index 1 @@ -180,7 +236,7 @@ class MenuSharedState { ValueNotifier get notifier => _latestOpenView; set latestOpenView(ViewPB? view) { - if (_latestOpenView.value != view) { + if (_latestOpenView.value?.id != view?.id) { _latestOpenView.value = view; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart new file mode 100644 index 0000000000..32ffefbe47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FavoriteFolder extends StatelessWidget { + const FavoriteFolder({ + super.key, + required this.views, + }); + + final List views; + + @override + Widget build(BuildContext context) { + if (views.isEmpty) { + return const SizedBox.shrink(); + } + return BlocProvider( + create: (context) => FolderBloc(type: FolderCategoryType.favorite) + ..add( + const FolderEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + FavoriteHeader( + onPressed: () => context + .read() + .add(const FolderEvent.expandOrUnExpand()), + onAdded: () => context + .read() + .add(const FolderEvent.expandOrUnExpand(isExpanded: true)), + ), + if (state.isExpanded) + ...views.map( + (view) => ViewItem( + key: ValueKey( + '${FolderCategoryType.favorite.name} ${view.id}', + ), + categoryType: FolderCategoryType.favorite, + isDraggable: false, + isFirstChild: view.id == views.first.id, + view: view, + level: 0, + onSelected: (view) { + getIt().latestOpenView = view; + context + .read() + .add(MenuEvent.openPage(view.plugin())); + }, + ), + ) + ], + ); + }, + ), + ); + } +} + +class FavoriteHeader extends StatefulWidget { + const FavoriteHeader({ + super.key, + required this.onPressed, + required this.onAdded, + }); + + final VoidCallback onPressed; + final VoidCallback onAdded; + + @override + State createState() => _FavoriteHeaderState(); +} + +class _FavoriteHeaderState extends State { + bool onHover = false; + + @override + Widget build(BuildContext context) { + const iconSize = 26.0; + return MouseRegion( + onEnter: (event) => setState(() => onHover = true), + onExit: (event) => setState(() => onHover = false), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FlowyTextButton( + LocaleKeys.sideBar_favorites.tr(), + tooltip: LocaleKeys.sideBar_clickToHideFavorites.tr(), + constraints: const BoxConstraints(maxHeight: iconSize), + padding: const EdgeInsets.all(4), + fillColor: Colors.transparent, + onPressed: widget.onPressed, + ), + ], + ), + ); + } +} 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 7b4cc03ec5..31dd28fa94 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 @@ -1,8 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/menu/menu_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/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -11,7 +12,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class PersonalFolder extends StatefulWidget { +class PersonalFolder extends StatelessWidget { const PersonalFolder({ super.key, required this.views, @@ -19,37 +20,49 @@ class PersonalFolder extends StatefulWidget { final List views; - @override - State createState() => _PersonalFolderState(); -} - -class _PersonalFolderState extends State { - bool isExpanded = true; - @override Widget build(BuildContext context) { - return Column( - children: [ - PersonalFolderHeader( - onPressed: () => setState( - () => isExpanded = !isExpanded, - ), - onAdded: () => setState(() => isExpanded = true), + return BlocProvider( + create: (context) => FolderBloc(type: FolderCategoryType.personal) + ..add( + const FolderEvent.initial(), ), - if (isExpanded) - ...widget.views.map( - (view) => ViewItem( - key: ValueKey(view.id), - isFirstChild: view.id == widget.views.first.id, - view: view, - level: 0, - onSelected: (view) { - getIt().latestOpenView = view; - context.read().add(MenuEvent.openPage(view.plugin())); - }, - ), - ) - ], + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + PersonalFolderHeader( + onPressed: () => context + .read() + .add(const FolderEvent.expandOrUnExpand()), + onAdded: () => context + .read() + .add(const FolderEvent.expandOrUnExpand(isExpanded: true)), + ), + if (state.isExpanded) + ...views.map( + (view) => ViewItem( + key: ValueKey( + '${FolderCategoryType.personal.name} ${view.id}', + ), + categoryType: FolderCategoryType.personal, + isFirstChild: view.id == views.first.id, + view: view, + level: 0, + onSelected: (view) { + getIt().add( + TabsEvent.openPlugin( + plugin: view.plugin(), + view: view, + ), + ); + }, + ), + ) + ], + ); + }, + ), ); } } 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 590b10063a..8045fe08b4 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,4 +1,5 @@ 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/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart'; @@ -33,22 +34,43 @@ class HomeSideBar extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => MenuBloc( - user: user, - workspace: workspaceSetting.workspace, - )..add(const MenuEvent.initial()), - child: BlocConsumer( - builder: (context, state) => _buildSidebar(context, state), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => MenuBloc( + user: user, + workspace: workspaceSetting.workspace, + )..add(const MenuEvent.initial()), + ), + BlocProvider( + create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), + ) + ], + child: BlocListener( listenWhen: (p, c) => p.plugin.id != c.plugin.id, listener: (context, state) => getIt().add( TabsEvent.openPlugin(plugin: state.plugin), ), + child: Builder( + builder: (context) { + final menuState = context.watch().state; + final favoriteState = context.watch().state; + return _buildSidebar( + context, + menuState, + favoriteState, + ); + }, + ), ), ); } - Widget _buildSidebar(BuildContext context, MenuState state) { + Widget _buildSidebar( + BuildContext context, + MenuState state, + FavoriteState favoriteState, + ) { final views = state.views; return Container( decoration: BoxDecoration( @@ -67,13 +89,13 @@ class HomeSideBar extends StatelessWidget { const SidebarTopMenu(), // user, setting SidebarUser(user: user), - // Favorite, Not supported yet const VSpace(20), // scrollable document list Expanded( child: SingleChildScrollView( child: SidebarFolder( views: views, + favoriteViews: favoriteState.views, ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart index 2e268312f5..8f7ee3ea01 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_folder.dart @@ -1,23 +1,40 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SidebarFolder extends StatelessWidget { const SidebarFolder({ super.key, required this.views, + required this.favoriteViews, }); final List views; + final List favoriteViews; @override Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - // personal - PersonalFolder(views: views), - ], + return ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (context, value, child) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // favorite + if (favoriteViews.isNotEmpty) + FavoriteFolder( + views: favoriteViews, + ), + const VSpace(10), + // personal + PersonalFolder(views: views), + ], + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart index 32371d8af8..cce5ef0e13 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; enum ViewMoreActionType { delete, - addToFavorites, // not supported yet. + favorite, + unFavorite, duplicate, copyLink, // not supported yet. rename, @@ -18,8 +19,10 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { switch (this) { case ViewMoreActionType.delete: return LocaleKeys.disclosureAction_delete.tr(); - case ViewMoreActionType.addToFavorites: - return LocaleKeys.disclosureAction_addToFavorites.tr(); + case ViewMoreActionType.favorite: + return LocaleKeys.disclosureAction_favorite.tr(); + case ViewMoreActionType.unFavorite: + return LocaleKeys.disclosureAction_unfavorite.tr(); case ViewMoreActionType.duplicate: return LocaleKeys.disclosureAction_duplicate.tr(); case ViewMoreActionType.copyLink: @@ -37,8 +40,10 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType { switch (this) { case ViewMoreActionType.delete: return const FlowySvg(name: 'editor/delete'); - case ViewMoreActionType.addToFavorites: - return const Icon(Icons.favorite); + case ViewMoreActionType.favorite: + return const FlowySvg(name: 'home/unfavorite'); + case ViewMoreActionType.unFavorite: + return const FlowySvg(name: 'home/favorite'); case ViewMoreActionType.duplicate: return const FlowySvg(name: 'editor/copy'); case ViewMoreActionType.copyLink: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 983e4ccc0a..687b41a3f3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -1,5 +1,7 @@ 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/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -21,6 +23,7 @@ class ViewItem extends StatelessWidget { const ViewItem({ super.key, required this.view, + required this.categoryType, required this.level, this.leftPadding = 10, required this.onSelected, @@ -30,6 +33,8 @@ class ViewItem extends StatelessWidget { final ViewPB view; + final FolderCategoryType categoryType; + // indicate the level of the view item // used to calculate the left padding final int level; @@ -53,11 +58,10 @@ class ViewItem extends StatelessWidget { create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), child: BlocBuilder( builder: (context, state) { - view.childViews - ..clear() - ..addAll(state.childViews); return InnerViewItem( - view: view, + view: state.view, + childViews: state.childViews, + categoryType: categoryType, level: level, leftPadding: leftPadding, showActions: state.isEditing, @@ -76,6 +80,8 @@ class InnerViewItem extends StatelessWidget { const InnerViewItem({ super.key, required this.view, + required this.childViews, + required this.categoryType, this.isDraggable = true, this.isExpanded = true, required this.level, @@ -86,6 +92,8 @@ class InnerViewItem extends StatelessWidget { }); final ViewPB view; + final List childViews; + final FolderCategoryType categoryType; final bool isDraggable; final bool isExpanded; @@ -108,11 +116,11 @@ class InnerViewItem extends StatelessWidget { ); // if the view is expanded and has child views, render its child views - final childViews = view.childViews; if (isExpanded && childViews.isNotEmpty) { final children = childViews.map((childView) { return ViewItem( - key: ValueKey(childView.id), + key: ValueKey('${categoryType.name} ${childView.id}'), + categoryType: categoryType, isFirstChild: childView.id == childViews.first.id, view: childView, level: level + 1, @@ -139,12 +147,19 @@ class InnerViewItem extends StatelessWidget { feedback: (context) { return ViewItem( view: view, + categoryType: categoryType, level: level, onSelected: onSelected, isDraggable: false, ); }, ); + } else { + // keep the same height of the DraggableItem + child = Padding( + padding: const EdgeInsets.only(top: 2.0), + child: child, + ); } return child; @@ -218,7 +233,8 @@ class _SingleInnerViewItemState extends State { children.add(_buildViewAddButton(context)); } - return GestureDetector( + // Don't use GestureDetector here, because it doesn't response to the tap event sometimes. + return InkWell( onTap: () => widget.onSelected(widget.view), child: SizedBox( height: 26, @@ -284,10 +300,17 @@ class _SingleInnerViewItemState extends State { return Tooltip( message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), child: ViewMoreActionButton( + view: widget.view, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), onAction: (action) { switch (action) { + case ViewMoreActionType.favorite: + case ViewMoreActionType.unFavorite: + context + .read() + .add(FavoriteEvent.toggle(widget.view)); + break; case ViewMoreActionType.rename: NavigatorTextFieldDialog( title: LocaleKeys.disclosureAction_rename.tr(), 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 da2b367011..d236157fe8 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 @@ -1,4 +1,5 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flowy_infra/image.dart'; @@ -6,26 +7,30 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -const supportedActionTypes = [ - ViewMoreActionType.rename, - ViewMoreActionType.delete, - ViewMoreActionType.duplicate, - ViewMoreActionType.openInNewTab, -]; - /// ยทยทยท button beside the view name class ViewMoreActionButton extends StatelessWidget { const ViewMoreActionButton({ super.key, + required this.view, required this.onEditing, required this.onAction, }); + final ViewPB view; final void Function(bool value) onEditing; final void Function(ViewMoreActionType) onAction; @override Widget build(BuildContext context) { + final supportedActionTypes = [ + ViewMoreActionType.rename, + ViewMoreActionType.delete, + ViewMoreActionType.duplicate, + ViewMoreActionType.openInNewTab, + view.isFavorite + ? ViewMoreActionType.unFavorite + : ViewMoreActionType.favorite, + ]; return PopoverActionList( direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 8), 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 2f0e94f346..5d28a0e103 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 @@ -32,11 +32,8 @@ void main() { menuBloc.add(const MenuEvent.createApp("App 3")); await blocResponseFuture(); - menuBloc.add(const MenuEvent.moveApp(1, 3)); - await blocResponseFuture(); - + assert(menuBloc.state.views[0].name == 'App 3'); assert(menuBloc.state.views[1].name == 'App 2'); - assert(menuBloc.state.views[2].name == 'App 3'); - assert(menuBloc.state.views[3].name == 'App 1'); + assert(menuBloc.state.views[2].name == 'App 1'); }); } diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 8df9825d0a..30c2d14593 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -34,13 +34,12 @@ default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] [patch.crates-io] -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } +appflowy-integrate = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-database = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-document = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-folder = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-plugins = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} #collab = { path = "../../AppFlowy-Collab/collab" } #collab-folder = { path = "../../AppFlowy-Collab/collab-folder" } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 83edee46e6..5882346062 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -70,6 +70,8 @@ "rename": "Rename", "delete": "Delete", "duplicate": "Duplicate", + "unfavorite": "Remove from favorites", + "favorite": "Add to favorites", "openNewTab": "Open in a new tab", "moveTo": "Move to", "addToFavorites": "Add to Favorites", @@ -154,6 +156,7 @@ "personal": "Personal", "favorites": "Favorites", "clickToHidePersonal": "Click to hide personal section", + "clickToHideFavorites": "Click to hide favorite section", "addAPage": "Add a page" }, "notifications": { diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 87a8270e7d..ba2a0e6bd5 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -85,7 +85,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" [[package]] name = "appflowy-integrate" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "anyhow", "collab", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "anyhow", "bytes", @@ -906,7 +906,7 @@ dependencies = [ [[package]] name = "collab-client-ws" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "bytes", "collab-sync", @@ -924,7 +924,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "anyhow", "async-trait", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "collab-derive" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "proc-macro2", "quote", @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "anyhow", "collab", @@ -982,7 +982,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "anyhow", "chrono", @@ -1002,7 +1002,7 @@ dependencies = [ [[package]] name = "collab-persistence" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "bincode", "chrono", @@ -1022,7 +1022,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "anyhow", "async-trait", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "collab-sync" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5" dependencies = [ "bytes", "collab", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index b811ec78fc..88adb1eedd 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -38,12 +38,12 @@ opt-level = 3 incremental = false [patch.crates-io] -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } +appflowy-integrate = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-database = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-document = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-folder = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} +collab-plugins = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"} #collab = { path = "../AppFlowy-Collab/collab" } #collab-folder = { path = "../AppFlowy-Collab/collab-folder" } @@ -51,4 +51,3 @@ collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = #collab-document = { path = "../AppFlowy-Collab/collab-document" } #collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" } #appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" } - diff --git a/frontend/rust-lib/flowy-folder2/src/entities/view.rs b/frontend/rust-lib/flowy-folder2/src/entities/view.rs index b08da9d7d5..dc809fc994 100644 --- a/frontend/rust-lib/flowy-folder2/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder2/src/entities/view.rs @@ -58,6 +58,9 @@ pub struct ViewPB { /// The cover url of the view. #[pb(index = 8, one_of)] pub cover_url: Option, + + #[pb(index = 9)] + pub is_favorite: bool, } pub fn view_pb_without_child_views(view: Arc) -> ViewPB { @@ -70,6 +73,7 @@ pub fn view_pb_without_child_views(view: Arc) -> ViewPB { layout: view.layout.clone().into(), icon_url: view.icon_url.clone(), cover_url: view.cover_url.clone(), + is_favorite: view.is_favorite.clone(), } } @@ -87,6 +91,7 @@ pub fn view_pb_with_child_views(view: Arc, child_views: Vec>) -> layout: view.layout.clone().into(), icon_url: view.icon_url.clone(), cover_url: view.cover_url.clone(), + is_favorite: view.is_favorite.clone(), } } @@ -174,9 +179,14 @@ pub struct CreateViewPayloadPB { #[pb(index = 7)] pub meta: HashMap, - /// Mark the view as current view after creation. + // Mark the view as current view after creation. #[pb(index = 8)] pub set_as_current: bool, + + // The index of the view in the parent view. + // If the index is None or the index is out of range, the view will be appended to the end of the parent view. + #[pb(index = 9, one_of)] + pub index: Option, } /// The orphan view is meant to be a view that is not attached to any parent view. By default, this @@ -209,8 +219,11 @@ pub struct CreateViewParams { pub view_id: String, pub initial_data: Vec, pub meta: HashMap, - /// Mark the view as current view after creation. + // Mark the view as current view after creation. pub set_as_current: bool, + // The index of the view in the parent view. + // If the index is None or the index is out of range, the view will be appended to the end of the parent view. + pub index: Option, } impl TryInto for CreateViewPayloadPB { @@ -230,6 +243,7 @@ impl TryInto for CreateViewPayloadPB { initial_data: self.initial_data, meta: self.meta, set_as_current: self.set_as_current, + index: self.index, }) } } @@ -250,6 +264,7 @@ impl TryInto for CreateOrphanViewPayloadPB { initial_data: self.initial_data, meta: Default::default(), set_as_current: false, + index: None, }) } } @@ -307,6 +322,9 @@ pub struct UpdateViewPayloadPB { #[pb(index = 7, one_of)] pub cover_url: Option, + + #[pb(index = 8, one_of)] + pub is_favorite: Option, } #[derive(Clone, Debug)] @@ -315,13 +333,10 @@ pub struct UpdateViewParams { pub name: Option, pub desc: Option, pub thumbnail: Option, - pub layout: Option, - - /// The icon url can be empty, which means the view has no icon. pub icon_url: Option, - - /// The cover url can be empty, which means the view has no icon. pub cover_url: Option, + pub is_favorite: Option, + pub layout: Option, } impl TryInto for UpdateViewPayloadPB { @@ -345,14 +360,19 @@ impl TryInto for UpdateViewPayloadPB { Some(thumbnail) => Some(ViewThumbnail::parse(thumbnail)?.0), }; + let cover_url = self.cover_url; + let icon_url = self.icon_url; + let is_favorite = self.is_favorite; + Ok(UpdateViewParams { view_id, name, desc, thumbnail, + cover_url, + icon_url, + is_favorite, layout: self.layout.map(|ty| ty.into()), - icon_url: self.icon_url, - cover_url: self.cover_url, }) } } diff --git a/frontend/rust-lib/flowy-folder2/src/event_handler.rs b/frontend/rust-lib/flowy-folder2/src/event_handler.rs index cace27739a..a8b4fd4aca 100644 --- a/frontend/rust-lib/flowy-folder2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder2/src/event_handler.rs @@ -1,11 +1,10 @@ use std::sync::{Arc, Weak}; -use flowy_error::{FlowyError, FlowyResult}; -use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; - use crate::entities::*; use crate::manager::FolderManager; use crate::share::ImportParams; +use flowy_error::{FlowyError, FlowyResult}; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult}; fn upgrade_folder( folder_manager: AFPluginState>, @@ -152,6 +151,18 @@ pub(crate) async fn delete_view_handler( Ok(()) } +pub(crate) async fn toggle_favorites_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> Result<(), FlowyError> { + let params: RepeatedViewIdPB = data.into_inner(); + let folder = upgrade_folder(folder)?; + for view_id in ¶ms.items { + let _ = folder.toggle_favorites(view_id).await; + } + Ok(()) +} + pub(crate) async fn set_latest_view_handler( data: AFPluginData, folder: AFPluginState>, @@ -208,6 +219,27 @@ pub(crate) async fn duplicate_view_handler( Ok(()) } +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn read_favorites_handler( + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let favorites = folder.get_all_favorites().await; + let mut views = vec![]; + for info in favorites { + let view = folder.get_view(&info.id).await; + match view { + Ok(view) => { + views.push(view); + }, + Err(err) => { + return Err(err.into()); + }, + } + } + + data_result_ok(RepeatedViewPB { items: views }) +} #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn read_trash_handler( folder: AFPluginState>, diff --git a/frontend/rust-lib/flowy-folder2/src/event_map.rs b/frontend/rust-lib/flowy-folder2/src/event_map.rs index 3d3eceecd3..fa568f3f9d 100644 --- a/frontend/rust-lib/flowy-folder2/src/event_map.rs +++ b/frontend/rust-lib/flowy-folder2/src/event_map.rs @@ -38,6 +38,8 @@ pub fn init(folder: Weak) -> AFPlugin { .event(FolderEvent::DeleteAllTrash, delete_all_trash_handler) .event(FolderEvent::ImportData, import_data_handler) .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler) + .event(FolderEvent::ReadFavorites, read_favorites_handler) + .event(FolderEvent::ToggleFavorite, toggle_favorites_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -133,7 +135,6 @@ pub enum FolderEvent { #[event()] GetFolderSnapshots = 31, - /// Moves a nested view to a new location in the hierarchy. /// /// This function takes the `view_id` of the view to be moved, @@ -142,4 +143,10 @@ pub enum FolderEvent { /// this specific view. #[event(input = "MoveNestedViewPayloadPB")] MoveNestedView = 32, + + #[event(output = "RepeatedViewPB")] + ReadFavorites = 33, + + #[event(input = "RepeatedViewIdPB")] + ToggleFavorite = 34, } diff --git a/frontend/rust-lib/flowy-folder2/src/manager.rs b/frontend/rust-lib/flowy-folder2/src/manager.rs index 15c59d0f77..c7218db7ee 100644 --- a/frontend/rust-lib/flowy-folder2/src/manager.rs +++ b/frontend/rust-lib/flowy-folder2/src/manager.rs @@ -7,8 +7,8 @@ use appflowy_integrate::{CollabPersistenceConfig, CollabType, RocksCollabDB}; use collab::core::collab::{CollabRawData, MutexCollab}; use collab::core::collab_state::SyncState; use collab_folder::core::{ - Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, View, ViewChange, - ViewChangeReceiver, ViewLayout, Workspace, + FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, + View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace, }; use parking_lot::Mutex; use tokio_stream::wrappers::WatchStream; @@ -370,9 +370,10 @@ impl FolderManager { .await?; } + let index = params.index; let view = create_view(params, view_layout); self.with_folder((), |folder| { - folder.insert_view(view.clone()); + folder.insert_view(view.clone(), index); }); Ok(view) @@ -393,7 +394,7 @@ impl FolderManager { .await?; let view = create_view(params, view_layout); self.with_folder((), |folder| { - folder.insert_view(view.clone()); + folder.insert_view(view.clone(), None); }); Ok(view) } @@ -442,21 +443,21 @@ impl FolderManager { /// Move the view to trash. If the view is the current view, then set the current view to empty. /// When the view is moved to trash, all the child views will be moved to trash as well. + /// All the favorite views being trashed will be unfavorited first to remove it from favorites list as well. The process of unfavoriting concerned view is handled by `unfavorite_view_and_decendants()` #[tracing::instrument(level = "debug", skip(self), err)] pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> { self.with_folder((), |folder| { - let view = folder.views.get_view(view_id); - folder.add_trash(vec![view_id.to_string()]); + if let Some(view) = folder.views.get_view(view_id) { + self.unfavorite_view_and_decendants(view.clone(), &folder); + folder.add_trash(vec![view_id.to_string()]); + // notify the parent view that the view is moved to trash + send_notification(view_id, FolderNotification::DidMoveViewToTrash) + .payload(DeletedViewPB { + view_id: view_id.to_string(), + index: None, + }) + .send(); - // notify the parent view that the view is moved to trash - send_notification(view_id, FolderNotification::DidMoveViewToTrash) - .payload(DeletedViewPB { - view_id: view_id.to_string(), - index: None, - }) - .send(); - - if let Some(view) = view { notify_child_views_changed( view_pb_without_child_views(view), ChildViewChangeReason::DidDeleteView, @@ -467,6 +468,31 @@ impl FolderManager { Ok(()) } + fn unfavorite_view_and_decendants(&self, view: Arc, folder: &Folder) { + let mut all_descendant_views: Vec> = vec![view.clone()]; + all_descendant_views.extend(folder.views.get_views_belong_to(&view.id)); + + let favorite_descendant_views: Vec = all_descendant_views + .iter() + .filter(|view| view.is_favorite) + .map(|view| view_pb_without_child_views(view.clone())) + .collect(); + + if !favorite_descendant_views.is_empty() { + folder.delete_favorites( + favorite_descendant_views + .iter() + .map(|v| v.id.clone()) + .collect(), + ); + send_notification("favorite", FolderNotification::DidUnfavoriteView) + .payload(RepeatedViewPB { + items: favorite_descendant_views, + }) + .send(); + } + } + /// Moves a nested view to a new location in the hierarchy. /// /// This function takes the `view_id` of the view to be moved, @@ -570,6 +596,7 @@ impl FolderManager { .set_layout_if_not_none(params.layout) .set_icon_url_if_not_none(params.icon_url) .set_cover_url_if_not_none(params.cover_url) + .set_favorite_if_not_none(params.is_favorite) .done() }); @@ -599,6 +626,14 @@ impl FolderManager { let handler = self.get_handler(&view.layout)?; let view_data = handler.duplicate_view(&view.id).await?; + + // get the current view index in the parent view, because we need to insert the duplicated view below the current view. + let index = if let Some((_, __, views)) = self.get_view_relation(&view.parent_view_id).await { + views.iter().position(|id| id == view_id).map(|i| i as u32) + } else { + None + }; + let duplicate_params = CreateViewParams { parent_view_id: view.parent_view_id.clone(), name: format!("{} (copy)", &view.name), @@ -608,9 +643,10 @@ impl FolderManager { view_id: gen_view_id(), meta: Default::default(), set_as_current: true, + index, }; - let _ = self.create_view_with_params(duplicate_params).await?; + self.create_view_with_params(duplicate_params).await?; Ok(()) } @@ -634,6 +670,57 @@ impl FolderManager { self.get_view(&view_id).await.ok() } + /// Toggles the favorite status of a view identified by `view_id`If the view is not a favorite, it will be added to the favorites list; otherwise, it will be removed from the list. + #[tracing::instrument(level = "debug", skip(self), err)] + pub async fn toggle_favorites(&self, view_id: &str) -> FlowyResult<()> { + self.with_folder((), |folder| { + if let Some(old_view) = folder.views.get_view(view_id) { + if old_view.is_favorite { + folder.delete_favorites(vec![view_id.to_string()]); + } else { + folder.add_favorites(vec![view_id.to_string()]); + } + } + }); + self.send_toggle_favorite_notification(view_id).await; + Ok(()) + } + + // Used by toggle_favorites to send notification to frontend, after the favorite status of view has been changed.It sends two distinct notifications: one to correctly update the concerned view's is_favorite status, and another to update the list of favorites that is to be displayed. + async fn send_toggle_favorite_notification(&self, view_id: &str) { + if let Ok(view) = self.get_view(view_id).await { + let notification_type = if view.is_favorite { + FolderNotification::DidFavoriteView + } else { + FolderNotification::DidUnfavoriteView + }; + send_notification("favorite", notification_type) + .payload(RepeatedViewPB { + items: vec![view.clone()], + }) + .send(); + + send_notification(&view.id, FolderNotification::DidUpdateView) + .payload(view) + .send() + } + } + + #[tracing::instrument(level = "trace", skip(self))] + pub(crate) async fn get_all_favorites(&self) -> Vec { + self.with_folder(vec![], |folder| { + let trash_ids = folder + .get_all_trash() + .into_iter() + .map(|trash| trash.id) + .collect::>(); + + let mut views = folder.get_all_favorites(); + views.retain(|view| !trash_ids.contains(&view.id)); + views + }) + } + #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn get_all_trash(&self) -> Vec { self.with_folder(vec![], |folder| folder.get_all_trash()) @@ -644,7 +731,6 @@ impl FolderManager { self.with_folder((), |folder| { folder.remote_all_trash(); }); - send_notification("trash", FolderNotification::DidUpdateTrash) .payload(RepeatedTrashPB { items: vec![] }) .send(); @@ -718,11 +804,12 @@ impl FolderManager { view_id, meta: Default::default(), set_as_current: false, + index: None, }; let view = create_view(params, import_data.view_layout); self.with_folder((), |folder| { - folder.insert_view(view.clone()); + folder.insert_view(view.clone(), None); }); notify_parent_view_did_change(self.mutex_folder.clone(), vec![view.parent_view_id.clone()]); Ok(view) diff --git a/frontend/rust-lib/flowy-folder2/src/notification.rs b/frontend/rust-lib/flowy-folder2/src/notification.rs index 878b33b134..2d41567b57 100644 --- a/frontend/rust-lib/flowy-folder2/src/notification.rs +++ b/frontend/rust-lib/flowy-folder2/src/notification.rs @@ -34,6 +34,9 @@ pub enum FolderNotification { DidUpdateTrash = 15, DidUpdateFolderSnapshotState = 16, DidUpdateFolderSyncUpdate = 17, + + DidFavoriteView = 36, + DidUnfavoriteView = 37, } impl std::convert::From for i32 { @@ -57,6 +60,8 @@ impl std::convert::From for FolderNotification { 15 => FolderNotification::DidUpdateTrash, 16 => FolderNotification::DidUpdateFolderSnapshotState, 17 => FolderNotification::DidUpdateFolderSyncUpdate, + 36 => FolderNotification::DidFavoriteView, + 37 => FolderNotification::DidUnfavoriteView, _ => FolderNotification::Unknown, } } diff --git a/frontend/rust-lib/flowy-folder2/src/test_helper.rs b/frontend/rust-lib/flowy-folder2/src/test_helper.rs index fbd95ee832..18872cc7d9 100644 --- a/frontend/rust-lib/flowy-folder2/src/test_helper.rs +++ b/frontend/rust-lib/flowy-folder2/src/test_helper.rs @@ -44,6 +44,7 @@ impl FolderManager { initial_data: vec![], meta: ext, set_as_current: true, + index: None, }; self.create_view_with_params(params).await.unwrap(); view_id diff --git a/frontend/rust-lib/flowy-folder2/src/view_operation.rs b/frontend/rust-lib/flowy-folder2/src/view_operation.rs index 46e0b3a161..f2ec98ed5a 100644 --- a/frontend/rust-lib/flowy-folder2/src/view_operation.rs +++ b/frontend/rust-lib/flowy-folder2/src/view_operation.rs @@ -54,6 +54,7 @@ pub struct ViewBuilder { desc: String, layout: ViewLayout, child_views: Vec, + is_favorite: bool, icon_url: Option, cover_url: Option, } @@ -67,6 +68,7 @@ impl ViewBuilder { desc: Default::default(), layout: ViewLayout::Document, child_views: vec![], + is_favorite: false, icon_url: None, cover_url: None, } @@ -110,6 +112,7 @@ impl ViewBuilder { name: self.name, desc: self.desc, created_at: timestamp(), + is_favorite: self.is_favorite, layout: self.layout, icon_url: self.icon_url, cover_url: self.cover_url, @@ -252,9 +255,10 @@ pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View desc: params.desc, children: Default::default(), created_at: time, + is_favorite: false, layout, - icon_url: None, cover_url: None, + icon_url: None, } } diff --git a/frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs b/frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs index 4bc754598d..cd8187ebef 100644 --- a/frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs +++ b/frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs @@ -98,6 +98,7 @@ async fn update_parent_view_test() { UpdateParentView { name: Some(new_name.clone()), desc: None, + is_favorite: None, }, ReloadParentView(parent_view.id), ]) @@ -143,6 +144,7 @@ async fn view_update() { UpdateView { name: Some(new_name.clone()), desc: None, + is_favorite: None, }, ReadView(view.id), ]) @@ -249,6 +251,56 @@ async fn view_delete_all_permanent() { assert_eq!(test.trash.len(), 0); } +#[tokio::test] +async fn toggle_favorites() { + let mut test = FolderTest::new().await; + let view = test.child_view.clone(); + test + .run_scripts(vec![ + ReadView(view.id.clone()), + ToggleFavorite, + ReadFavorites, + ReadView(view.id.clone()), + ]) + .await; + assert_eq!(test.child_view.is_favorite, true); + assert!(test.favorites.len() != 0); + assert_eq!(test.favorites[0].id, view.id); + + let view = test.child_view.clone(); + test + .run_scripts(vec![ + ReadView(view.id.clone()), + ToggleFavorite, + ReadFavorites, + ReadView(view.id.clone()), + ]) + .await; + + assert!(!test.child_view.is_favorite); + assert!(test.favorites.is_empty()); +} + +#[tokio::test] +async fn delete_favorites() { + let mut test = FolderTest::new().await; + let view = test.child_view.clone(); + test + .run_scripts(vec![ + ReadView(view.id.clone()), + ToggleFavorite, + ReadFavorites, + ReadView(view.id.clone()), + ]) + .await; + assert_eq!(test.child_view.is_favorite, true); + assert!(test.favorites.len() != 0); + assert_eq!(test.favorites[0].id, view.id); + + test.run_scripts(vec![DeleteView, ReadFavorites]).await; + assert!(test.favorites.len() == 0); +} + #[tokio::test] async fn move_view_event_test() { let mut test = FolderTest::new().await; diff --git a/frontend/rust-lib/flowy-folder2/tests/workspace/script.rs b/frontend/rust-lib/flowy-folder2/tests/workspace/script.rs index bee8b92828..9390859edb 100644 --- a/frontend/rust-lib/flowy-folder2/tests/workspace/script.rs +++ b/frontend/rust-lib/flowy-folder2/tests/workspace/script.rs @@ -25,6 +25,7 @@ pub enum FolderScript { UpdateParentView { name: Option, desc: Option, + is_favorite: Option, }, DeleteParentView, @@ -39,6 +40,7 @@ pub enum FolderScript { UpdateView { name: Option, desc: Option, + is_favorite: Option, }, DeleteView, DeleteViews(Vec), @@ -53,6 +55,8 @@ pub enum FolderScript { RestoreViewFromTrash, ReadTrash, DeleteAllTrash, + ToggleFavorite, + ReadFavorites, } pub struct FolderTest { @@ -62,6 +66,7 @@ pub struct FolderTest { pub parent_view: ViewPB, pub child_view: ViewPB, pub trash: Vec, + pub favorites: Vec, } impl FolderTest { @@ -85,6 +90,7 @@ impl FolderTest { parent_view, child_view: view, trash: vec![], + favorites: vec![], } } @@ -123,8 +129,12 @@ impl FolderTest { let parent_view = read_view(sdk, &parent_view_id).await; self.parent_view = parent_view; }, - FolderScript::UpdateParentView { name, desc } => { - update_view(sdk, &self.parent_view.id, name, desc).await; + FolderScript::UpdateParentView { + name, + desc, + is_favorite, + } => { + update_view(sdk, &self.parent_view.id, name, desc, is_favorite).await; }, FolderScript::DeleteParentView => { delete_view(sdk, vec![self.parent_view.id.clone()]).await; @@ -147,8 +157,12 @@ impl FolderTest { let view = read_view(sdk, &view_id).await; self.child_view = view; }, - FolderScript::UpdateView { name, desc } => { - update_view(sdk, &self.child_view.id, name, desc).await; + FolderScript::UpdateView { + name, + desc, + is_favorite, + } => { + update_view(sdk, &self.child_view.id, name, desc, is_favorite).await; }, FolderScript::DeleteView => { delete_view(sdk, vec![self.child_view.id.clone()]).await; @@ -170,6 +184,13 @@ impl FolderTest { delete_all_trash(sdk).await; self.trash = vec![]; }, + FolderScript::ToggleFavorite => { + toggle_favorites(sdk, vec![self.child_view.id.clone()]).await; + }, + FolderScript::ReadFavorites => { + let favorites = read_favorites(sdk).await; + self.favorites = favorites.to_vec(); + }, } } } @@ -223,6 +244,7 @@ pub async fn create_app(sdk: &FlowyCoreTest, workspace_id: &str, name: &str, des initial_data: vec![], meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(sdk.clone()) @@ -249,6 +271,7 @@ pub async fn create_view( initial_data: vec![], meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(sdk.clone()) .event(CreateView) @@ -293,11 +316,14 @@ pub async fn update_view( view_id: &str, name: Option, desc: Option, + is_favorite: Option, ) { + println!("Toggling update view {:?}", is_favorite); let request = UpdateViewPayloadPB { view_id: view_id.to_string(), name, desc, + is_favorite, ..Default::default() }; EventBuilder::new(sdk.clone()) @@ -352,3 +378,20 @@ pub async fn delete_all_trash(sdk: &FlowyCoreTest) { .async_send() .await; } + +pub async fn toggle_favorites(sdk: &FlowyCoreTest, view_id: Vec) { + let request = RepeatedViewIdPB { items: view_id }; + EventBuilder::new(sdk.clone()) + .event(ToggleFavorite) + .payload(request) + .async_send() + .await; +} + +pub async fn read_favorites(sdk: &FlowyCoreTest) -> RepeatedViewPB { + EventBuilder::new(sdk.clone()) + .event(ReadFavorites) + .async_send() + .await + .parse::() +} diff --git a/frontend/rust-lib/flowy-test/src/document/document_event.rs b/frontend/rust-lib/flowy-test/src/document/document_event.rs index 2cb04d741f..8b6704303a 100644 --- a/frontend/rust-lib/flowy-test/src/document/document_event.rs +++ b/frontend/rust-lib/flowy-test/src/document/document_event.rs @@ -41,6 +41,7 @@ impl DocumentEventTest { initial_data: vec![], meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(core.clone()) .event(FolderEvent::CreateView) diff --git a/frontend/rust-lib/flowy-test/src/folder_event.rs b/frontend/rust-lib/flowy-test/src/folder_event.rs index 4b235b0205..a6cbebf09d 100644 --- a/frontend/rust-lib/flowy-test/src/folder_event.rs +++ b/frontend/rust-lib/flowy-test/src/folder_event.rs @@ -77,6 +77,7 @@ async fn create_app(sdk: &FlowyCoreTest, name: &str, desc: &str, workspace_id: & initial_data: vec![], meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(sdk.clone()) @@ -102,6 +103,7 @@ async fn create_view( initial_data: data, meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(sdk.clone()) diff --git a/frontend/rust-lib/flowy-test/src/lib.rs b/frontend/rust-lib/flowy-test/src/lib.rs index b2f2fa2475..464f9e8fde 100644 --- a/frontend/rust-lib/flowy-test/src/lib.rs +++ b/frontend/rust-lib/flowy-test/src/lib.rs @@ -190,6 +190,7 @@ impl FlowyCoreTest { initial_data: vec![], meta: Default::default(), set_as_current: false, + index: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -214,6 +215,7 @@ impl FlowyCoreTest { initial_data, meta: Default::default(), set_as_current: true, + index: None, }; let view = EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -246,6 +248,7 @@ impl FlowyCoreTest { initial_data, meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -275,6 +278,7 @@ impl FlowyCoreTest { initial_data, meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -299,6 +303,7 @@ impl FlowyCoreTest { initial_data, meta: Default::default(), set_as_current: true, + index: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView)