feat: support favorites folder

This commit is contained in:
Mihir 2023-08-02 18:50:51 +05:30 committed by GitHub
parent ff79635b2b
commit a1143e24f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1268 additions and 172 deletions

View File

@ -1,3 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 6L18.781 11.9243L25 12.8801L20.5 17.489L21.562 24L16 20.9243L10.438 24L11.5 17.489L7 12.8801L13.219 11.9243L16 6Z" fill="#FFD667" stroke="#FFD667" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 329 B

View File

@ -1,3 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 6L18.781 11.9243L25 12.8801L20.5 17.489L21.562 24L16 20.9243L10.438 24L11.5 17.489L7 12.8801L13.219 11.9243L16 6Z" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

View File

@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4.5L14.0858 8.94322L18.75 9.66009L15.375 13.1167L16.1715 18L12 15.6932L7.8285 18L8.625 13.1167L5.25 9.66009L9.91425 8.94322L12 4.5Z" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 3L9.3905 5.96215L12.5 6.44006L10.25 8.74448L10.781 12L8 10.4621L5.219 12L5.75 8.74448L3.5 6.44006L6.6095 5.96215L8 3Z" fill="#FFD667" stroke="#FFD667" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 334 B

After

Width:  |  Height:  |  Size: 315 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 3L9.3905 5.96215L12.5 6.44006L10.25 8.74448L10.781 12L8 10.4621L5.219 12L5.75 8.74448L3.5 6.44006L6.6095 5.96215L8 3Z" fill="#none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@ -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);
});
});
}

View File

@ -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,
);
},
);
});
}

View File

@ -1,10 +1,14 @@
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'sidebar_test.dart' as sidebar_test; 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() { void startTesting() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Sidebar integration tests // Sidebar integration tests
sidebar_test.main(); sidebar_test.main();
sidebar_expanded_test.main();
sidebar_favorite_test.main();
} }

View File

@ -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/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/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_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:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -199,6 +199,18 @@ extension CommonOperations on WidgetTester {
await tapButtonWithName(ViewMoreActionType.rename.name); await tapButtonWithName(ViewMoreActionType.rename.name);
} }
/// Tap the favorite page button
Future<void> tapFavoritePageButton() async {
await tapPageOptionButton();
await tapButtonWithName(ViewMoreActionType.favorite.name);
}
/// Tap the unfavorite page button
Future<void> tapUnfavoritePageButton() async {
await tapPageOptionButton();
await tapButtonWithName(ViewMoreActionType.unFavorite.name);
}
/// Rename the page. /// Rename the page.
Future<void> renamePage(String name) async { Future<void> renamePage(String name) async {
await tapRenamePageButton(); await tapRenamePageButton();
@ -332,6 +344,36 @@ extension CommonOperations on WidgetTester {
await pumpAndSettle(); await pumpAndSettle();
} }
Future<void> favoriteViewByName(
String name, {
ViewLayoutPB layout = ViewLayoutPB.Document,
}) async {
await hoverOnPageName(
name,
layout: layout,
useLast: false,
onHover: () async {
await tapFavoritePageButton();
await pumpAndSettle();
},
);
}
Future<void> unfavoriteViewByName(
String name, {
ViewLayoutPB layout = ViewLayoutPB.Document,
}) async {
await hoverOnPageName(
name,
layout: layout,
useLast: false,
onHover: () async {
await tapUnfavoritePageButton();
await pumpAndSettle();
},
);
}
Future<void> movePageToOtherPage({ Future<void> movePageToOtherPage({
required String name, required String name,
required String parentName, required String parentName,

View File

@ -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/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/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_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/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@ -147,7 +148,24 @@ extension Expectation on WidgetTester {
expect(textWidget, findsOneWidget); 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( Finder findPageName(
String name, { String name, {
ViewLayoutPB layout = ViewLayoutPB.Document, ViewLayoutPB layout = ViewLayoutPB.Document,
@ -168,11 +186,11 @@ extension Expectation on WidgetTester {
of: find.byWidgetPredicate( of: find.byWidgetPredicate(
(widget) => (widget) =>
widget is ViewItem && widget is ViewItem &&
widget.view.name == name && widget.view.name == parentName &&
widget.view.layout == layout, widget.view.layout == parentLayout,
skipOffstage: false, skipOffstage: false,
), ),
matching: findPageName(name), matching: findPageName(name, layout: layout),
); );
} }
} }

View File

@ -35,4 +35,10 @@ class KVKeys {
/// The value is a json string with the following format: /// The value is a json string with the following format:
/// {'viewId': true, 'viewId2': false} /// {'viewId': true, 'viewId2': false}
static const String expandedViews = 'expandedViews'; 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';
} }

View File

@ -15,7 +15,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/image.dart';
import 'package:appflowy/workspace/application/view/view_ext.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:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart';
@ -155,11 +154,10 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
onSelected: (action, controller) async { onSelected: (action, controller) async {
switch (action.inner) { switch (action.inner) {
case _ActionType.viewDatabase: case _ActionType.viewDatabase:
getIt<MenuSharedState>().latestOpenView = viewPB;
getIt<TabsBloc>().add( getIt<TabsBloc>().add(
TabsEvent.openPlugin( TabsEvent.openPlugin(
plugin: viewPB.plugin(), plugin: viewPB.plugin(),
view: viewPB,
), ),
); );
break; break;

View File

@ -4,7 +4,6 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.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/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart' import 'package:appflowy_editor/appflowy_editor.dart'
@ -109,10 +108,10 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
Log.error('Page($pageId) not found'); Log.error('Page($pageId) not found');
return; return;
} }
getIt<MenuSharedState>().latestOpenView = view;
getIt<TabsBloc>().add( getIt<TabsBloc>().add(
TabsEvent.openPlugin( TabsEvent.openPlugin(
plugin: view.plugin(), plugin: view.plugin(),
view: view,
), ),
); );
} }

View File

@ -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_impl.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:appflowy/plugins/document/application/prelude.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/user/prelude.dart';
import 'package:appflowy/workspace/application/workspace/prelude.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart';
import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.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<TrashBloc>(
() => TrashBloc(), () => TrashBloc(),
); );
getIt.registerFactory<FavoriteBloc>(() => FavoriteBloc());
} }
void _resolveDocDeps(GetIt getIt) { void _resolveDocDeps(GetIt getIt) {

View File

@ -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<FavoriteEvent, FavoriteState> {
final _service = FavoriteService();
final _listener = FavoriteListener();
FavoriteBloc() : super(FavoriteState.initial()) {
on<FavoriteEvent>(
(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<FlowyError, RepeatedViewPB> 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<ViewPB> views,
}) = _FavoriteState;
factory FavoriteState.initial() => const FavoriteState(
views: [],
);
}

View File

@ -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<FlowyError, RepeatedViewPB> result,
bool isFavorite,
);
class FavoriteListener {
StreamSubscription<SubscribeObject>? _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<Uint8List, FlowyError> 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<void> stop() async {
_parser = null;
await _streamSubscription?.cancel();
_favoriteUpdated = null;
}
}

View File

@ -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<Either<RepeatedViewPB, FlowyError>> readFavorites() {
return FolderEventReadFavorites().send();
}
Future<Either<Unit, FlowyError>> toggleFavorite(
String viewId,
bool favoriteStatus,
) async {
final id = RepeatedViewIdPB.create()..items.add(viewId);
return FolderEventToggleFavorite(id).send();
}
}

View File

@ -0,0 +1,3 @@
export 'favorite_bloc.dart';
export 'favorite_listener.dart';
export 'favorite_service.dart';

View File

@ -41,7 +41,8 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
createApp: (_CreateApp event) async { createApp: (_CreateApp event) async {
final result = await _workspaceService.createApp( final result = await _workspaceService.createApp(
name: event.name, name: event.name,
desc: event.desc ?? "", desc: event.desc,
index: 0, // default to the first index
); );
result.fold( result.fold(
(app) => emit(state.copyWith(plugin: app.plugin())), (app) => emit(state.copyWith(plugin: app.plugin())),

View File

@ -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<FolderEvent, FolderState> {
FolderBloc({
required FolderCategoryType type,
}) : super(FolderState.initial(type)) {
on<FolderEvent>((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<void> _setFolderExpandStatus(bool isExpanded) async {
final result = await getIt<KeyValueStorage>().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<KeyValueStorage>().set(KVKeys.expandedViews, jsonEncode(map));
}
Future<bool> _getFolderExpandStatus() async {
return getIt<KeyValueStorage>().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,
);
}

View File

@ -34,6 +34,10 @@ class ViewBackendService {
/// the database id. For example: "database_id": "xxx" /// the database id. For example: "database_id": "xxx"
/// ///
Map<String, String> ext = const {}, Map<String, String> 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() final payload = CreateViewPayloadPB.create()
..parentViewId = parentViewId ..parentViewId = parentViewId
@ -47,6 +51,14 @@ class ViewBackendService {
payload.meta.addAll(ext); payload.meta.addAll(ext);
} }
if (desc != null) {
payload.desc = desc;
}
if (index != null) {
payload.index = index;
}
return FolderEventCreateView(payload).send(); return FolderEventCreateView(payload).send();
} }
@ -118,11 +130,17 @@ class ViewBackendService {
return FolderEventDuplicateView(view).send(); return FolderEventDuplicateView(view).send();
} }
static Future<Either<Unit, FlowyError>> favorite({required String viewId}) {
final request = RepeatedViewIdPB.create()..items.add(viewId);
return FolderEventToggleFavorite(request).send();
}
static Future<Either<ViewPB, FlowyError>> updateView({ static Future<Either<ViewPB, FlowyError>> updateView({
required String viewId, required String viewId,
String? name, String? name,
String? iconURL, String? iconURL,
String? coverURL, String? coverURL,
bool? isFavorite,
}) { }) {
final payload = UpdateViewPayloadPB.create()..viewId = viewId; final payload = UpdateViewPayloadPB.create()..viewId = viewId;
@ -138,6 +156,9 @@ class ViewBackendService {
payload.coverUrl = coverURL; payload.coverUrl = coverURL;
} }
if (isFavorite != null) {
payload.isFavorite = isFavorite;
}
return FolderEventUpdateView(payload).send(); return FolderEventUpdateView(payload).send();
} }

View File

@ -15,16 +15,25 @@ class WorkspaceService {
WorkspaceService({ WorkspaceService({
required this.workspaceId, required this.workspaceId,
}); });
Future<Either<ViewPB, FlowyError>> createApp({ Future<Either<ViewPB, FlowyError>> createApp({
required String name, required String name,
String? desc, String? desc,
int? index,
}) { }) {
final payload = CreateViewPayloadPB.create() final payload = CreateViewPayloadPB.create()
..parentViewId = workspaceId ..parentViewId = workspaceId
..name = name ..name = name
..desc = desc ?? ""
..layout = ViewLayoutPB.Document; ..layout = ViewLayoutPB.Document;
if (desc != null) {
payload.desc = desc;
}
if (index != null) {
payload.index = index;
}
return FolderEventCreateView(payload).send(); return FolderEventCreateView(payload).send();
} }

View File

@ -1,4 +1,5 @@
import 'package:appflowy/startup/startup.dart'; 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/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart';
@ -92,6 +93,7 @@ class ViewSectionItem extends StatelessWidget {
if (onHover || state.isEditing) { if (onHover || state.isEditing) {
children.add( children.add(
ViewDisclosureButton( ViewDisclosureButton(
state: state,
onEdit: (isEdit) => onEdit: (isEdit) =>
blocContext.read<ViewBloc>().add(ViewEvent.setIsEditing(isEdit)), blocContext.read<ViewBloc>().add(ViewEvent.setIsEditing(isEdit)),
onAction: (action) { onAction: (action) {
@ -115,6 +117,11 @@ class ViewSectionItem extends StatelessWidget {
case ViewDisclosureAction.duplicate: case ViewDisclosureAction.duplicate:
blocContext.read<ViewBloc>().add(const ViewEvent.duplicate()); blocContext.read<ViewBloc>().add(const ViewEvent.duplicate());
break; break;
case ViewDisclosureAction.favorite:
blocContext
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(view));
break;
case ViewDisclosureAction.openInNewTab: case ViewDisclosureAction.openInNewTab:
blocContext.read<TabsBloc>().add( blocContext.read<TabsBloc>().add(
TabsEvent.openTab( TabsEvent.openTab(
@ -143,11 +150,12 @@ enum ViewDisclosureAction {
rename, rename,
delete, delete,
duplicate, duplicate,
favorite,
openInNewTab, openInNewTab,
} }
extension ViewDisclosureExtension on ViewDisclosureAction { extension ViewDisclosureExtension on ViewDisclosureAction {
String get name { String name({ViewState? state}) {
switch (this) { switch (this) {
case ViewDisclosureAction.rename: case ViewDisclosureAction.rename:
return LocaleKeys.disclosureAction_rename.tr(); return LocaleKeys.disclosureAction_rename.tr();
@ -155,12 +163,16 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
return LocaleKeys.disclosureAction_delete.tr(); return LocaleKeys.disclosureAction_delete.tr();
case ViewDisclosureAction.duplicate: case ViewDisclosureAction.duplicate:
return LocaleKeys.disclosureAction_duplicate.tr(); return LocaleKeys.disclosureAction_duplicate.tr();
case ViewDisclosureAction.favorite:
return state!.view.isFavorite
? LocaleKeys.disclosureAction_unfavorite.tr()
: LocaleKeys.disclosureAction_favorite.tr();
case ViewDisclosureAction.openInNewTab: case ViewDisclosureAction.openInNewTab:
return LocaleKeys.disclosureAction_openNewTab.tr(); return LocaleKeys.disclosureAction_openNewTab.tr();
} }
} }
Widget icon(Color iconColor) { Widget icon(Color iconColor, {ViewState? state}) {
switch (this) { switch (this) {
case ViewDisclosureAction.rename: case ViewDisclosureAction.rename:
return const FlowySvg(name: 'editor/edit'); return const FlowySvg(name: 'editor/edit');
@ -168,6 +180,10 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
return const FlowySvg(name: 'editor/delete'); return const FlowySvg(name: 'editor/delete');
case ViewDisclosureAction.duplicate: case ViewDisclosureAction.duplicate:
return const FlowySvg(name: 'editor/copy'); 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: case ViewDisclosureAction.openInNewTab:
return const FlowySvg(name: 'grid/expander'); return const FlowySvg(name: 'grid/expander');
} }
@ -177,9 +193,11 @@ extension ViewDisclosureExtension on ViewDisclosureAction {
class ViewDisclosureButton extends StatelessWidget { class ViewDisclosureButton extends StatelessWidget {
final Function(bool) onEdit; final Function(bool) onEdit;
final Function(ViewDisclosureAction) onAction; final Function(ViewDisclosureAction) onAction;
final ViewState state;
const ViewDisclosureButton({ const ViewDisclosureButton({
required this.onEdit, required this.onEdit,
required this.onAction, required this.onAction,
required this.state,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -188,7 +206,7 @@ class ViewDisclosureButton extends StatelessWidget {
return PopoverActionList<ViewDisclosureActionWrapper>( return PopoverActionList<ViewDisclosureActionWrapper>(
direction: PopoverDirection.bottomWithCenterAligned, direction: PopoverDirection.bottomWithCenterAligned,
actions: ViewDisclosureAction.values actions: ViewDisclosureAction.values
.map((action) => ViewDisclosureActionWrapper(action)) .map((action) => ViewDisclosureActionWrapper(action, state))
.toList(), .toList(),
buildChild: (controller) { buildChild: (controller) {
return FlowyIconButton( return FlowyIconButton(
@ -219,11 +237,12 @@ class ViewDisclosureButton extends StatelessWidget {
class ViewDisclosureActionWrapper extends ActionCell { class ViewDisclosureActionWrapper extends ActionCell {
final ViewDisclosureAction inner; final ViewDisclosureAction inner;
final ViewState? state;
ViewDisclosureActionWrapper(this.inner); ViewDisclosureActionWrapper(this.inner, [this.state]);
@override @override
Widget? leftIcon(Color iconColor) => inner.icon(iconColor); Widget? leftIcon(Color iconColor) => inner.icon(iconColor, state: state);
@override @override
String get name => inner.name; String get name => inner.name(state: state);
} }

View File

@ -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();
}
}

View File

@ -4,6 +4,7 @@ import 'package:appflowy/core/frameless_window.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/trash/menu.dart'; import 'package:appflowy/plugins/trash/menu.dart';
import 'package:appflowy/startup/startup.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/home/home_setting_bloc.dart';
import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_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 '../navigation.dart';
import 'app/create_button.dart'; import 'app/create_button.dart';
import 'app/menu_app.dart'; import 'app/menu_app.dart';
import 'app/section/item.dart';
import 'menu_user.dart'; import 'menu_user.dart';
export './app/header/header.dart'; export './app/header/header.dart';
@ -56,6 +58,10 @@ class HomeMenu extends StatelessWidget {
return menuBloc; return menuBloc;
}, },
), ),
BlocProvider(
create: (ctx) =>
getIt<FavoriteBloc>()..add(const FavoriteEvent.initial()),
)
], ],
child: MultiBlocListener( child: MultiBlocListener(
listeners: [ listeners: [
@ -105,6 +111,50 @@ class HomeMenu extends StatelessWidget {
); );
} }
Widget _renderFavorites(BuildContext context) {
return BlocBuilder<FavoriteBloc, FavoriteState>(
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<MenuSharedState>()
.latestOpenView = view,
view: e,
),
)
.toList(),
),
),
collapsed: const SizedBox.shrink(),
),
)
: const SizedBox.shrink();
},
);
}
Widget _renderApps(BuildContext context) { Widget _renderApps(BuildContext context) {
return ExpandableTheme( return ExpandableTheme(
data: ExpandableThemeData( data: ExpandableThemeData(
@ -122,10 +172,16 @@ class HomeMenu extends StatelessWidget {
return ReorderableListView.builder( return ReorderableListView.builder(
itemCount: menuItems.length, itemCount: menuItems.length,
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
header: Padding( header: Column(
padding: children: [
EdgeInsets.only(bottom: 20.0 - MenuAppSizes.appVPadding), Padding(
child: MenuUser(user), padding: EdgeInsets.only(
bottom: MenuAppSizes.appVPadding,
),
child: MenuUser(user),
),
_renderFavorites(context),
],
), ),
onReorder: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
// Moving item1 from index 0 to index 1 // Moving item1 from index 0 to index 1
@ -180,7 +236,7 @@ class MenuSharedState {
ValueNotifier<ViewPB?> get notifier => _latestOpenView; ValueNotifier<ViewPB?> get notifier => _latestOpenView;
set latestOpenView(ViewPB? view) { set latestOpenView(ViewPB? view) {
if (_latestOpenView.value != view) { if (_latestOpenView.value?.id != view?.id) {
_latestOpenView.value = view; _latestOpenView.value = view;
} }
} }

View File

@ -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<ViewPB> views;
@override
Widget build(BuildContext context) {
if (views.isEmpty) {
return const SizedBox.shrink();
}
return BlocProvider<FolderBloc>(
create: (context) => FolderBloc(type: FolderCategoryType.favorite)
..add(
const FolderEvent.initial(),
),
child: BlocBuilder<FolderBloc, FolderState>(
builder: (context, state) {
return Column(
children: [
FavoriteHeader(
onPressed: () => context
.read<FolderBloc>()
.add(const FolderEvent.expandOrUnExpand()),
onAdded: () => context
.read<FolderBloc>()
.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<MenuSharedState>().latestOpenView = view;
context
.read<MenuBloc>()
.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<FavoriteHeader> createState() => _FavoriteHeaderState();
}
class _FavoriteHeaderState extends State<FavoriteHeader> {
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,
),
],
),
);
}
}

View File

@ -1,8 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/menu/menu_bloc.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/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/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class PersonalFolder extends StatefulWidget { class PersonalFolder extends StatelessWidget {
const PersonalFolder({ const PersonalFolder({
super.key, super.key,
required this.views, required this.views,
@ -19,37 +20,49 @@ class PersonalFolder extends StatefulWidget {
final List<ViewPB> views; final List<ViewPB> views;
@override
State<PersonalFolder> createState() => _PersonalFolderState();
}
class _PersonalFolderState extends State<PersonalFolder> {
bool isExpanded = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return BlocProvider<FolderBloc>(
children: [ create: (context) => FolderBloc(type: FolderCategoryType.personal)
PersonalFolderHeader( ..add(
onPressed: () => setState( const FolderEvent.initial(),
() => isExpanded = !isExpanded,
),
onAdded: () => setState(() => isExpanded = true),
), ),
if (isExpanded) child: BlocBuilder<FolderBloc, FolderState>(
...widget.views.map( builder: (context, state) {
(view) => ViewItem( return Column(
key: ValueKey(view.id), children: [
isFirstChild: view.id == widget.views.first.id, PersonalFolderHeader(
view: view, onPressed: () => context
level: 0, .read<FolderBloc>()
onSelected: (view) { .add(const FolderEvent.expandOrUnExpand()),
getIt<MenuSharedState>().latestOpenView = view; onAdded: () => context
context.read<MenuBloc>().add(MenuEvent.openPage(view.plugin())); .read<FolderBloc>()
}, .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<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: view.plugin(),
view: view,
),
);
},
),
)
],
);
},
),
); );
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:appflowy/startup/startup.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/menu_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart';
@ -33,22 +34,43 @@ class HomeSideBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return MultiBlocProvider(
create: (_) => MenuBloc( providers: [
user: user, BlocProvider(
workspace: workspaceSetting.workspace, create: (_) => MenuBloc(
)..add(const MenuEvent.initial()), user: user,
child: BlocConsumer<MenuBloc, MenuState>( workspace: workspaceSetting.workspace,
builder: (context, state) => _buildSidebar(context, state), )..add(const MenuEvent.initial()),
),
BlocProvider(
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
)
],
child: BlocListener<MenuBloc, MenuState>(
listenWhen: (p, c) => p.plugin.id != c.plugin.id, listenWhen: (p, c) => p.plugin.id != c.plugin.id,
listener: (context, state) => getIt<TabsBloc>().add( listener: (context, state) => getIt<TabsBloc>().add(
TabsEvent.openPlugin(plugin: state.plugin), TabsEvent.openPlugin(plugin: state.plugin),
), ),
child: Builder(
builder: (context) {
final menuState = context.watch<MenuBloc>().state;
final favoriteState = context.watch<FavoriteBloc>().state;
return _buildSidebar(
context,
menuState,
favoriteState,
);
},
),
), ),
); );
} }
Widget _buildSidebar(BuildContext context, MenuState state) { Widget _buildSidebar(
BuildContext context,
MenuState state,
FavoriteState favoriteState,
) {
final views = state.views; final views = state.views;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -67,13 +89,13 @@ class HomeSideBar extends StatelessWidget {
const SidebarTopMenu(), const SidebarTopMenu(),
// user, setting // user, setting
SidebarUser(user: user), SidebarUser(user: user),
// Favorite, Not supported yet
const VSpace(20), const VSpace(20),
// scrollable document list // scrollable document list
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: SidebarFolder( child: SidebarFolder(
views: views, views: views,
favoriteViews: favoriteState.views,
), ),
), ),
), ),

View File

@ -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/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.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'; import 'package:flutter/material.dart';
class SidebarFolder extends StatelessWidget { class SidebarFolder extends StatelessWidget {
const SidebarFolder({ const SidebarFolder({
super.key, super.key,
required this.views, required this.views,
required this.favoriteViews,
}); });
final List<ViewPB> views; final List<ViewPB> views;
final List<ViewPB> favoriteViews;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return ValueListenableBuilder(
mainAxisAlignment: MainAxisAlignment.start, valueListenable: getIt<MenuSharedState>().notifier,
children: [ builder: (context, value, child) {
// personal return Column(
PersonalFolder(views: views), mainAxisAlignment: MainAxisAlignment.start,
], children: [
// favorite
if (favoriteViews.isNotEmpty)
FavoriteFolder(
views: favoriteViews,
),
const VSpace(10),
// personal
PersonalFolder(views: views),
],
);
},
); );
} }
} }

View File

@ -5,7 +5,8 @@ import 'package:flutter/material.dart';
enum ViewMoreActionType { enum ViewMoreActionType {
delete, delete,
addToFavorites, // not supported yet. favorite,
unFavorite,
duplicate, duplicate,
copyLink, // not supported yet. copyLink, // not supported yet.
rename, rename,
@ -18,8 +19,10 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
switch (this) { switch (this) {
case ViewMoreActionType.delete: case ViewMoreActionType.delete:
return LocaleKeys.disclosureAction_delete.tr(); return LocaleKeys.disclosureAction_delete.tr();
case ViewMoreActionType.addToFavorites: case ViewMoreActionType.favorite:
return LocaleKeys.disclosureAction_addToFavorites.tr(); return LocaleKeys.disclosureAction_favorite.tr();
case ViewMoreActionType.unFavorite:
return LocaleKeys.disclosureAction_unfavorite.tr();
case ViewMoreActionType.duplicate: case ViewMoreActionType.duplicate:
return LocaleKeys.disclosureAction_duplicate.tr(); return LocaleKeys.disclosureAction_duplicate.tr();
case ViewMoreActionType.copyLink: case ViewMoreActionType.copyLink:
@ -37,8 +40,10 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
switch (this) { switch (this) {
case ViewMoreActionType.delete: case ViewMoreActionType.delete:
return const FlowySvg(name: 'editor/delete'); return const FlowySvg(name: 'editor/delete');
case ViewMoreActionType.addToFavorites: case ViewMoreActionType.favorite:
return const Icon(Icons.favorite); return const FlowySvg(name: 'home/unfavorite');
case ViewMoreActionType.unFavorite:
return const FlowySvg(name: 'home/favorite');
case ViewMoreActionType.duplicate: case ViewMoreActionType.duplicate:
return const FlowySvg(name: 'editor/copy'); return const FlowySvg(name: 'editor/copy');
case ViewMoreActionType.copyLink: case ViewMoreActionType.copyLink:

View File

@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.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/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart';
@ -21,6 +23,7 @@ class ViewItem extends StatelessWidget {
const ViewItem({ const ViewItem({
super.key, super.key,
required this.view, required this.view,
required this.categoryType,
required this.level, required this.level,
this.leftPadding = 10, this.leftPadding = 10,
required this.onSelected, required this.onSelected,
@ -30,6 +33,8 @@ class ViewItem extends StatelessWidget {
final ViewPB view; final ViewPB view;
final FolderCategoryType categoryType;
// indicate the level of the view item // indicate the level of the view item
// used to calculate the left padding // used to calculate the left padding
final int level; final int level;
@ -53,11 +58,10 @@ class ViewItem extends StatelessWidget {
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
child: BlocBuilder<ViewBloc, ViewState>( child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) { builder: (context, state) {
view.childViews
..clear()
..addAll(state.childViews);
return InnerViewItem( return InnerViewItem(
view: view, view: state.view,
childViews: state.childViews,
categoryType: categoryType,
level: level, level: level,
leftPadding: leftPadding, leftPadding: leftPadding,
showActions: state.isEditing, showActions: state.isEditing,
@ -76,6 +80,8 @@ class InnerViewItem extends StatelessWidget {
const InnerViewItem({ const InnerViewItem({
super.key, super.key,
required this.view, required this.view,
required this.childViews,
required this.categoryType,
this.isDraggable = true, this.isDraggable = true,
this.isExpanded = true, this.isExpanded = true,
required this.level, required this.level,
@ -86,6 +92,8 @@ class InnerViewItem extends StatelessWidget {
}); });
final ViewPB view; final ViewPB view;
final List<ViewPB> childViews;
final FolderCategoryType categoryType;
final bool isDraggable; final bool isDraggable;
final bool isExpanded; final bool isExpanded;
@ -108,11 +116,11 @@ class InnerViewItem extends StatelessWidget {
); );
// if the view is expanded and has child views, render its child views // if the view is expanded and has child views, render its child views
final childViews = view.childViews;
if (isExpanded && childViews.isNotEmpty) { if (isExpanded && childViews.isNotEmpty) {
final children = childViews.map((childView) { final children = childViews.map((childView) {
return ViewItem( return ViewItem(
key: ValueKey(childView.id), key: ValueKey('${categoryType.name} ${childView.id}'),
categoryType: categoryType,
isFirstChild: childView.id == childViews.first.id, isFirstChild: childView.id == childViews.first.id,
view: childView, view: childView,
level: level + 1, level: level + 1,
@ -139,12 +147,19 @@ class InnerViewItem extends StatelessWidget {
feedback: (context) { feedback: (context) {
return ViewItem( return ViewItem(
view: view, view: view,
categoryType: categoryType,
level: level, level: level,
onSelected: onSelected, onSelected: onSelected,
isDraggable: false, isDraggable: false,
); );
}, },
); );
} else {
// keep the same height of the DraggableItem
child = Padding(
padding: const EdgeInsets.only(top: 2.0),
child: child,
);
} }
return child; return child;
@ -218,7 +233,8 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
children.add(_buildViewAddButton(context)); 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), onTap: () => widget.onSelected(widget.view),
child: SizedBox( child: SizedBox(
height: 26, height: 26,
@ -284,10 +300,17 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
return Tooltip( return Tooltip(
message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(),
child: ViewMoreActionButton( child: ViewMoreActionButton(
view: widget.view,
onEditing: (value) => onEditing: (value) =>
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)), context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
onAction: (action) { onAction: (action) {
switch (action) { switch (action) {
case ViewMoreActionType.favorite:
case ViewMoreActionType.unFavorite:
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view));
break;
case ViewMoreActionType.rename: case ViewMoreActionType.rename:
NavigatorTextFieldDialog( NavigatorTextFieldDialog(
title: LocaleKeys.disclosureAction_rename.tr(), title: LocaleKeys.disclosureAction_rename.tr(),

View File

@ -1,4 +1,5 @@
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; 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:flutter/material.dart';
import 'package:flowy_infra/image.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:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.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 /// ··· button beside the view name
class ViewMoreActionButton extends StatelessWidget { class ViewMoreActionButton extends StatelessWidget {
const ViewMoreActionButton({ const ViewMoreActionButton({
super.key, super.key,
required this.view,
required this.onEditing, required this.onEditing,
required this.onAction, required this.onAction,
}); });
final ViewPB view;
final void Function(bool value) onEditing; final void Function(bool value) onEditing;
final void Function(ViewMoreActionType) onAction; final void Function(ViewMoreActionType) onAction;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final supportedActionTypes = [
ViewMoreActionType.rename,
ViewMoreActionType.delete,
ViewMoreActionType.duplicate,
ViewMoreActionType.openInNewTab,
view.isFavorite
? ViewMoreActionType.unFavorite
: ViewMoreActionType.favorite,
];
return PopoverActionList<ViewMoreActionTypeWrapper>( return PopoverActionList<ViewMoreActionTypeWrapper>(
direction: PopoverDirection.bottomWithCenterAligned, direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 8), offset: const Offset(0, 8),

View File

@ -32,11 +32,8 @@ void main() {
menuBloc.add(const MenuEvent.createApp("App 3")); menuBloc.add(const MenuEvent.createApp("App 3"));
await blocResponseFuture(); await blocResponseFuture();
menuBloc.add(const MenuEvent.moveApp(1, 3)); assert(menuBloc.state.views[0].name == 'App 3');
await blocResponseFuture();
assert(menuBloc.state.views[1].name == 'App 2'); assert(menuBloc.state.views[1].name == 'App 2');
assert(menuBloc.state.views[2].name == 'App 3'); assert(menuBloc.state.views[2].name == 'App 1');
assert(menuBloc.state.views[3].name == 'App 1');
}); });
} }

View File

@ -34,13 +34,12 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io] [patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } appflowy-integrate = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-database = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-document = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-folder = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-plugins = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" }
#collab = { path = "../../AppFlowy-Collab/collab" } #collab = { path = "../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../../AppFlowy-Collab/collab-folder" } #collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }

View File

@ -70,6 +70,8 @@
"rename": "Rename", "rename": "Rename",
"delete": "Delete", "delete": "Delete",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"unfavorite": "Remove from favorites",
"favorite": "Add to favorites",
"openNewTab": "Open in a new tab", "openNewTab": "Open in a new tab",
"moveTo": "Move to", "moveTo": "Move to",
"addToFavorites": "Add to Favorites", "addToFavorites": "Add to Favorites",
@ -154,6 +156,7 @@
"personal": "Personal", "personal": "Personal",
"favorites": "Favorites", "favorites": "Favorites",
"clickToHidePersonal": "Click to hide personal section", "clickToHidePersonal": "Click to hide personal section",
"clickToHideFavorites": "Click to hide favorite section",
"addAPage": "Add a page" "addAPage": "Add a page"
}, },
"notifications": { "notifications": {

View File

@ -85,7 +85,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]] [[package]]
name = "appflowy-integrate" name = "appflowy-integrate"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -888,7 +888,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -906,7 +906,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-client-ws" name = "collab-client-ws"
version = "0.1.0" 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 = [ dependencies = [
"bytes", "bytes",
"collab-sync", "collab-sync",
@ -924,7 +924,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-database" name = "collab-database"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -951,7 +951,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-derive" name = "collab-derive"
version = "0.1.0" 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 = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -963,7 +963,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-document" name = "collab-document"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -982,7 +982,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-folder" name = "collab-folder"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -1002,7 +1002,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-persistence" name = "collab-persistence"
version = "0.1.0" 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 = [ dependencies = [
"bincode", "bincode",
"chrono", "chrono",
@ -1022,7 +1022,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-plugins" name = "collab-plugins"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -1052,7 +1052,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-sync" name = "collab-sync"
version = "0.1.0" 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 = [ dependencies = [
"bytes", "bytes",
"collab", "collab",

View File

@ -38,12 +38,12 @@ opt-level = 3
incremental = false incremental = false
[patch.crates-io] [patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } appflowy-integrate = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-database = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-document = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-folder = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "5783a5" } collab-plugins = {git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f9df5b9"}
#collab = { path = "../AppFlowy-Collab/collab" } #collab = { path = "../AppFlowy-Collab/collab" }
#collab-folder = { path = "../AppFlowy-Collab/collab-folder" } #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-document = { path = "../AppFlowy-Collab/collab-document" }
#collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" } #collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" }
#appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" } #appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" }

View File

@ -58,6 +58,9 @@ pub struct ViewPB {
/// The cover url of the view. /// The cover url of the view.
#[pb(index = 8, one_of)] #[pb(index = 8, one_of)]
pub cover_url: Option<String>, pub cover_url: Option<String>,
#[pb(index = 9)]
pub is_favorite: bool,
} }
pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB { pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB {
@ -70,6 +73,7 @@ pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB {
layout: view.layout.clone().into(), layout: view.layout.clone().into(),
icon_url: view.icon_url.clone(), icon_url: view.icon_url.clone(),
cover_url: view.cover_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<View>, child_views: Vec<Arc<View>>) ->
layout: view.layout.clone().into(), layout: view.layout.clone().into(),
icon_url: view.icon_url.clone(), icon_url: view.icon_url.clone(),
cover_url: view.cover_url.clone(), cover_url: view.cover_url.clone(),
is_favorite: view.is_favorite.clone(),
} }
} }
@ -174,9 +179,14 @@ pub struct CreateViewPayloadPB {
#[pb(index = 7)] #[pb(index = 7)]
pub meta: HashMap<String, String>, pub meta: HashMap<String, String>,
/// Mark the view as current view after creation. // Mark the view as current view after creation.
#[pb(index = 8)] #[pb(index = 8)]
pub set_as_current: bool, 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<u32>,
} }
/// The orphan view is meant to be a view that is not attached to any parent view. By default, this /// 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 view_id: String,
pub initial_data: Vec<u8>, pub initial_data: Vec<u8>,
pub meta: HashMap<String, String>, pub meta: HashMap<String, String>,
/// Mark the view as current view after creation. // Mark the view as current view after creation.
pub set_as_current: bool, 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<u32>,
} }
impl TryInto<CreateViewParams> for CreateViewPayloadPB { impl TryInto<CreateViewParams> for CreateViewPayloadPB {
@ -230,6 +243,7 @@ impl TryInto<CreateViewParams> for CreateViewPayloadPB {
initial_data: self.initial_data, initial_data: self.initial_data,
meta: self.meta, meta: self.meta,
set_as_current: self.set_as_current, set_as_current: self.set_as_current,
index: self.index,
}) })
} }
} }
@ -250,6 +264,7 @@ impl TryInto<CreateViewParams> for CreateOrphanViewPayloadPB {
initial_data: self.initial_data, initial_data: self.initial_data,
meta: Default::default(), meta: Default::default(),
set_as_current: false, set_as_current: false,
index: None,
}) })
} }
} }
@ -307,6 +322,9 @@ pub struct UpdateViewPayloadPB {
#[pb(index = 7, one_of)] #[pb(index = 7, one_of)]
pub cover_url: Option<String>, pub cover_url: Option<String>,
#[pb(index = 8, one_of)]
pub is_favorite: Option<bool>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -315,13 +333,10 @@ pub struct UpdateViewParams {
pub name: Option<String>, pub name: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
pub thumbnail: Option<String>, pub thumbnail: Option<String>,
pub layout: Option<ViewLayout>,
/// The icon url can be empty, which means the view has no icon.
pub icon_url: Option<String>, pub icon_url: Option<String>,
/// The cover url can be empty, which means the view has no icon.
pub cover_url: Option<String>, pub cover_url: Option<String>,
pub is_favorite: Option<bool>,
pub layout: Option<ViewLayout>,
} }
impl TryInto<UpdateViewParams> for UpdateViewPayloadPB { impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
@ -345,14 +360,19 @@ impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
Some(thumbnail) => Some(ViewThumbnail::parse(thumbnail)?.0), 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 { Ok(UpdateViewParams {
view_id, view_id,
name, name,
desc, desc,
thumbnail, thumbnail,
cover_url,
icon_url,
is_favorite,
layout: self.layout.map(|ty| ty.into()), layout: self.layout.map(|ty| ty.into()),
icon_url: self.icon_url,
cover_url: self.cover_url,
}) })
} }
} }

View File

@ -1,11 +1,10 @@
use std::sync::{Arc, Weak}; 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::entities::*;
use crate::manager::FolderManager; use crate::manager::FolderManager;
use crate::share::ImportParams; use crate::share::ImportParams;
use flowy_error::{FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
fn upgrade_folder( fn upgrade_folder(
folder_manager: AFPluginState<Weak<FolderManager>>, folder_manager: AFPluginState<Weak<FolderManager>>,
@ -152,6 +151,18 @@ pub(crate) async fn delete_view_handler(
Ok(()) Ok(())
} }
pub(crate) async fn toggle_favorites_handler(
data: AFPluginData<RepeatedViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>,
) -> Result<(), FlowyError> {
let params: RepeatedViewIdPB = data.into_inner();
let folder = upgrade_folder(folder)?;
for view_id in &params.items {
let _ = folder.toggle_favorites(view_id).await;
}
Ok(())
}
pub(crate) async fn set_latest_view_handler( pub(crate) async fn set_latest_view_handler(
data: AFPluginData<ViewIdPB>, data: AFPluginData<ViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>, folder: AFPluginState<Weak<FolderManager>>,
@ -208,6 +219,27 @@ pub(crate) async fn duplicate_view_handler(
Ok(()) Ok(())
} }
#[tracing::instrument(level = "debug", skip(folder), err)]
pub(crate) async fn read_favorites_handler(
folder: AFPluginState<Weak<FolderManager>>,
) -> DataResult<RepeatedViewPB, FlowyError> {
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)] #[tracing::instrument(level = "debug", skip(folder), err)]
pub(crate) async fn read_trash_handler( pub(crate) async fn read_trash_handler(
folder: AFPluginState<Weak<FolderManager>>, folder: AFPluginState<Weak<FolderManager>>,

View File

@ -38,6 +38,8 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin {
.event(FolderEvent::DeleteAllTrash, delete_all_trash_handler) .event(FolderEvent::DeleteAllTrash, delete_all_trash_handler)
.event(FolderEvent::ImportData, import_data_handler) .event(FolderEvent::ImportData, import_data_handler)
.event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_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)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@ -133,7 +135,6 @@ pub enum FolderEvent {
#[event()] #[event()]
GetFolderSnapshots = 31, GetFolderSnapshots = 31,
/// Moves a nested view to a new location in the hierarchy. /// Moves a nested view to a new location in the hierarchy.
/// ///
/// This function takes the `view_id` of the view to be moved, /// This function takes the `view_id` of the view to be moved,
@ -142,4 +143,10 @@ pub enum FolderEvent {
/// this specific view. /// this specific view.
#[event(input = "MoveNestedViewPayloadPB")] #[event(input = "MoveNestedViewPayloadPB")]
MoveNestedView = 32, MoveNestedView = 32,
#[event(output = "RepeatedViewPB")]
ReadFavorites = 33,
#[event(input = "RepeatedViewIdPB")]
ToggleFavorite = 34,
} }

View File

@ -7,8 +7,8 @@ use appflowy_integrate::{CollabPersistenceConfig, CollabType, RocksCollabDB};
use collab::core::collab::{CollabRawData, MutexCollab}; use collab::core::collab::{CollabRawData, MutexCollab};
use collab::core::collab_state::SyncState; use collab::core::collab_state::SyncState;
use collab_folder::core::{ use collab_folder::core::{
Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, View, ViewChange, FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo,
ViewChangeReceiver, ViewLayout, Workspace, View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use tokio_stream::wrappers::WatchStream; use tokio_stream::wrappers::WatchStream;
@ -370,9 +370,10 @@ impl FolderManager {
.await?; .await?;
} }
let index = params.index;
let view = create_view(params, view_layout); let view = create_view(params, view_layout);
self.with_folder((), |folder| { self.with_folder((), |folder| {
folder.insert_view(view.clone()); folder.insert_view(view.clone(), index);
}); });
Ok(view) Ok(view)
@ -393,7 +394,7 @@ impl FolderManager {
.await?; .await?;
let view = create_view(params, view_layout); let view = create_view(params, view_layout);
self.with_folder((), |folder| { self.with_folder((), |folder| {
folder.insert_view(view.clone()); folder.insert_view(view.clone(), None);
}); });
Ok(view) 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. /// 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. /// 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)] #[tracing::instrument(level = "debug", skip(self), err)]
pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> { pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
self.with_folder((), |folder| { self.with_folder((), |folder| {
let view = folder.views.get_view(view_id); if let Some(view) = folder.views.get_view(view_id) {
folder.add_trash(vec![view_id.to_string()]); 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( notify_child_views_changed(
view_pb_without_child_views(view), view_pb_without_child_views(view),
ChildViewChangeReason::DidDeleteView, ChildViewChangeReason::DidDeleteView,
@ -467,6 +468,31 @@ impl FolderManager {
Ok(()) Ok(())
} }
fn unfavorite_view_and_decendants(&self, view: Arc<View>, folder: &Folder) {
let mut all_descendant_views: Vec<Arc<View>> = vec![view.clone()];
all_descendant_views.extend(folder.views.get_views_belong_to(&view.id));
let favorite_descendant_views: Vec<ViewPB> = 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. /// Moves a nested view to a new location in the hierarchy.
/// ///
/// This function takes the `view_id` of the view to be moved, /// 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_layout_if_not_none(params.layout)
.set_icon_url_if_not_none(params.icon_url) .set_icon_url_if_not_none(params.icon_url)
.set_cover_url_if_not_none(params.cover_url) .set_cover_url_if_not_none(params.cover_url)
.set_favorite_if_not_none(params.is_favorite)
.done() .done()
}); });
@ -599,6 +626,14 @@ impl FolderManager {
let handler = self.get_handler(&view.layout)?; let handler = self.get_handler(&view.layout)?;
let view_data = handler.duplicate_view(&view.id).await?; 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 { let duplicate_params = CreateViewParams {
parent_view_id: view.parent_view_id.clone(), parent_view_id: view.parent_view_id.clone(),
name: format!("{} (copy)", &view.name), name: format!("{} (copy)", &view.name),
@ -608,9 +643,10 @@ impl FolderManager {
view_id: gen_view_id(), view_id: gen_view_id(),
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index,
}; };
let _ = self.create_view_with_params(duplicate_params).await?; self.create_view_with_params(duplicate_params).await?;
Ok(()) Ok(())
} }
@ -634,6 +670,57 @@ impl FolderManager {
self.get_view(&view_id).await.ok() 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<FavoritesInfo> {
self.with_folder(vec![], |folder| {
let trash_ids = folder
.get_all_trash()
.into_iter()
.map(|trash| trash.id)
.collect::<Vec<String>>();
let mut views = folder.get_all_favorites();
views.retain(|view| !trash_ids.contains(&view.id));
views
})
}
#[tracing::instrument(level = "trace", skip(self))] #[tracing::instrument(level = "trace", skip(self))]
pub(crate) async fn get_all_trash(&self) -> Vec<TrashInfo> { pub(crate) async fn get_all_trash(&self) -> Vec<TrashInfo> {
self.with_folder(vec![], |folder| folder.get_all_trash()) self.with_folder(vec![], |folder| folder.get_all_trash())
@ -644,7 +731,6 @@ impl FolderManager {
self.with_folder((), |folder| { self.with_folder((), |folder| {
folder.remote_all_trash(); folder.remote_all_trash();
}); });
send_notification("trash", FolderNotification::DidUpdateTrash) send_notification("trash", FolderNotification::DidUpdateTrash)
.payload(RepeatedTrashPB { items: vec![] }) .payload(RepeatedTrashPB { items: vec![] })
.send(); .send();
@ -718,11 +804,12 @@ impl FolderManager {
view_id, view_id,
meta: Default::default(), meta: Default::default(),
set_as_current: false, set_as_current: false,
index: None,
}; };
let view = create_view(params, import_data.view_layout); let view = create_view(params, import_data.view_layout);
self.with_folder((), |folder| { 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()]); notify_parent_view_did_change(self.mutex_folder.clone(), vec![view.parent_view_id.clone()]);
Ok(view) Ok(view)

View File

@ -34,6 +34,9 @@ pub enum FolderNotification {
DidUpdateTrash = 15, DidUpdateTrash = 15,
DidUpdateFolderSnapshotState = 16, DidUpdateFolderSnapshotState = 16,
DidUpdateFolderSyncUpdate = 17, DidUpdateFolderSyncUpdate = 17,
DidFavoriteView = 36,
DidUnfavoriteView = 37,
} }
impl std::convert::From<FolderNotification> for i32 { impl std::convert::From<FolderNotification> for i32 {
@ -57,6 +60,8 @@ impl std::convert::From<i32> for FolderNotification {
15 => FolderNotification::DidUpdateTrash, 15 => FolderNotification::DidUpdateTrash,
16 => FolderNotification::DidUpdateFolderSnapshotState, 16 => FolderNotification::DidUpdateFolderSnapshotState,
17 => FolderNotification::DidUpdateFolderSyncUpdate, 17 => FolderNotification::DidUpdateFolderSyncUpdate,
36 => FolderNotification::DidFavoriteView,
37 => FolderNotification::DidUnfavoriteView,
_ => FolderNotification::Unknown, _ => FolderNotification::Unknown,
} }
} }

View File

@ -44,6 +44,7 @@ impl FolderManager {
initial_data: vec![], initial_data: vec![],
meta: ext, meta: ext,
set_as_current: true, set_as_current: true,
index: None,
}; };
self.create_view_with_params(params).await.unwrap(); self.create_view_with_params(params).await.unwrap();
view_id view_id

View File

@ -54,6 +54,7 @@ pub struct ViewBuilder {
desc: String, desc: String,
layout: ViewLayout, layout: ViewLayout,
child_views: Vec<ParentChildViews>, child_views: Vec<ParentChildViews>,
is_favorite: bool,
icon_url: Option<String>, icon_url: Option<String>,
cover_url: Option<String>, cover_url: Option<String>,
} }
@ -67,6 +68,7 @@ impl ViewBuilder {
desc: Default::default(), desc: Default::default(),
layout: ViewLayout::Document, layout: ViewLayout::Document,
child_views: vec![], child_views: vec![],
is_favorite: false,
icon_url: None, icon_url: None,
cover_url: None, cover_url: None,
} }
@ -110,6 +112,7 @@ impl ViewBuilder {
name: self.name, name: self.name,
desc: self.desc, desc: self.desc,
created_at: timestamp(), created_at: timestamp(),
is_favorite: self.is_favorite,
layout: self.layout, layout: self.layout,
icon_url: self.icon_url, icon_url: self.icon_url,
cover_url: self.cover_url, cover_url: self.cover_url,
@ -252,9 +255,10 @@ pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View
desc: params.desc, desc: params.desc,
children: Default::default(), children: Default::default(),
created_at: time, created_at: time,
is_favorite: false,
layout, layout,
icon_url: None,
cover_url: None, cover_url: None,
icon_url: None,
} }
} }

View File

@ -98,6 +98,7 @@ async fn update_parent_view_test() {
UpdateParentView { UpdateParentView {
name: Some(new_name.clone()), name: Some(new_name.clone()),
desc: None, desc: None,
is_favorite: None,
}, },
ReloadParentView(parent_view.id), ReloadParentView(parent_view.id),
]) ])
@ -143,6 +144,7 @@ async fn view_update() {
UpdateView { UpdateView {
name: Some(new_name.clone()), name: Some(new_name.clone()),
desc: None, desc: None,
is_favorite: None,
}, },
ReadView(view.id), ReadView(view.id),
]) ])
@ -249,6 +251,56 @@ async fn view_delete_all_permanent() {
assert_eq!(test.trash.len(), 0); 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] #[tokio::test]
async fn move_view_event_test() { async fn move_view_event_test() {
let mut test = FolderTest::new().await; let mut test = FolderTest::new().await;

View File

@ -25,6 +25,7 @@ pub enum FolderScript {
UpdateParentView { UpdateParentView {
name: Option<String>, name: Option<String>,
desc: Option<String>, desc: Option<String>,
is_favorite: Option<bool>,
}, },
DeleteParentView, DeleteParentView,
@ -39,6 +40,7 @@ pub enum FolderScript {
UpdateView { UpdateView {
name: Option<String>, name: Option<String>,
desc: Option<String>, desc: Option<String>,
is_favorite: Option<bool>,
}, },
DeleteView, DeleteView,
DeleteViews(Vec<String>), DeleteViews(Vec<String>),
@ -53,6 +55,8 @@ pub enum FolderScript {
RestoreViewFromTrash, RestoreViewFromTrash,
ReadTrash, ReadTrash,
DeleteAllTrash, DeleteAllTrash,
ToggleFavorite,
ReadFavorites,
} }
pub struct FolderTest { pub struct FolderTest {
@ -62,6 +66,7 @@ pub struct FolderTest {
pub parent_view: ViewPB, pub parent_view: ViewPB,
pub child_view: ViewPB, pub child_view: ViewPB,
pub trash: Vec<TrashPB>, pub trash: Vec<TrashPB>,
pub favorites: Vec<ViewPB>,
} }
impl FolderTest { impl FolderTest {
@ -85,6 +90,7 @@ impl FolderTest {
parent_view, parent_view,
child_view: view, child_view: view,
trash: vec![], trash: vec![],
favorites: vec![],
} }
} }
@ -123,8 +129,12 @@ impl FolderTest {
let parent_view = read_view(sdk, &parent_view_id).await; let parent_view = read_view(sdk, &parent_view_id).await;
self.parent_view = parent_view; self.parent_view = parent_view;
}, },
FolderScript::UpdateParentView { name, desc } => { FolderScript::UpdateParentView {
update_view(sdk, &self.parent_view.id, name, desc).await; name,
desc,
is_favorite,
} => {
update_view(sdk, &self.parent_view.id, name, desc, is_favorite).await;
}, },
FolderScript::DeleteParentView => { FolderScript::DeleteParentView => {
delete_view(sdk, vec![self.parent_view.id.clone()]).await; delete_view(sdk, vec![self.parent_view.id.clone()]).await;
@ -147,8 +157,12 @@ impl FolderTest {
let view = read_view(sdk, &view_id).await; let view = read_view(sdk, &view_id).await;
self.child_view = view; self.child_view = view;
}, },
FolderScript::UpdateView { name, desc } => { FolderScript::UpdateView {
update_view(sdk, &self.child_view.id, name, desc).await; name,
desc,
is_favorite,
} => {
update_view(sdk, &self.child_view.id, name, desc, is_favorite).await;
}, },
FolderScript::DeleteView => { FolderScript::DeleteView => {
delete_view(sdk, vec![self.child_view.id.clone()]).await; delete_view(sdk, vec![self.child_view.id.clone()]).await;
@ -170,6 +184,13 @@ impl FolderTest {
delete_all_trash(sdk).await; delete_all_trash(sdk).await;
self.trash = vec![]; 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![], initial_data: vec![],
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(sdk.clone()) EventBuilder::new(sdk.clone())
@ -249,6 +271,7 @@ pub async fn create_view(
initial_data: vec![], initial_data: vec![],
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(sdk.clone()) EventBuilder::new(sdk.clone())
.event(CreateView) .event(CreateView)
@ -293,11 +316,14 @@ pub async fn update_view(
view_id: &str, view_id: &str,
name: Option<String>, name: Option<String>,
desc: Option<String>, desc: Option<String>,
is_favorite: Option<bool>,
) { ) {
println!("Toggling update view {:?}", is_favorite);
let request = UpdateViewPayloadPB { let request = UpdateViewPayloadPB {
view_id: view_id.to_string(), view_id: view_id.to_string(),
name, name,
desc, desc,
is_favorite,
..Default::default() ..Default::default()
}; };
EventBuilder::new(sdk.clone()) EventBuilder::new(sdk.clone())
@ -352,3 +378,20 @@ pub async fn delete_all_trash(sdk: &FlowyCoreTest) {
.async_send() .async_send()
.await; .await;
} }
pub async fn toggle_favorites(sdk: &FlowyCoreTest, view_id: Vec<String>) {
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::<RepeatedViewPB>()
}

View File

@ -41,6 +41,7 @@ impl DocumentEventTest {
initial_data: vec![], initial_data: vec![],
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(core.clone()) EventBuilder::new(core.clone())
.event(FolderEvent::CreateView) .event(FolderEvent::CreateView)

View File

@ -77,6 +77,7 @@ async fn create_app(sdk: &FlowyCoreTest, name: &str, desc: &str, workspace_id: &
initial_data: vec![], initial_data: vec![],
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(sdk.clone()) EventBuilder::new(sdk.clone())
@ -102,6 +103,7 @@ async fn create_view(
initial_data: data, initial_data: data,
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(sdk.clone()) EventBuilder::new(sdk.clone())

View File

@ -190,6 +190,7 @@ impl FlowyCoreTest {
initial_data: vec![], initial_data: vec![],
meta: Default::default(), meta: Default::default(),
set_as_current: false, set_as_current: false,
index: None,
}; };
EventBuilder::new(self.clone()) EventBuilder::new(self.clone())
.event(FolderEvent::CreateView) .event(FolderEvent::CreateView)
@ -214,6 +215,7 @@ impl FlowyCoreTest {
initial_data, initial_data,
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
let view = EventBuilder::new(self.clone()) let view = EventBuilder::new(self.clone())
.event(FolderEvent::CreateView) .event(FolderEvent::CreateView)
@ -246,6 +248,7 @@ impl FlowyCoreTest {
initial_data, initial_data,
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(self.clone()) EventBuilder::new(self.clone())
.event(FolderEvent::CreateView) .event(FolderEvent::CreateView)
@ -275,6 +278,7 @@ impl FlowyCoreTest {
initial_data, initial_data,
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(self.clone()) EventBuilder::new(self.clone())
.event(FolderEvent::CreateView) .event(FolderEvent::CreateView)
@ -299,6 +303,7 @@ impl FlowyCoreTest {
initial_data, initial_data,
meta: Default::default(), meta: Default::default(),
set_as_current: true, set_as_current: true,
index: None,
}; };
EventBuilder::new(self.clone()) EventBuilder::new(self.clone())
.event(FolderEvent::CreateView) .event(FolderEvent::CreateView)