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">
<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"/>
<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="#FFD667" stroke="#FFD667" stroke-linecap="round" stroke-linejoin="round"/>
</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 'sidebar_test.dart' as sidebar_test;
import 'sidebar_expand_test.dart' as sidebar_expanded_test;
import 'sidebar_favorites_test.dart' as sidebar_favorite_test;
void startTesting() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Sidebar integration tests
sidebar_test.main();
sidebar_expanded_test.main();
sidebar_favorite_test.main();
}

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/view/draggable_view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
@ -14,6 +13,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_langua
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -199,6 +199,18 @@ extension CommonOperations on WidgetTester {
await tapButtonWithName(ViewMoreActionType.rename.name);
}
/// Tap the favorite page button
Future<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.
Future<void> renamePage(String name) async {
await tapRenamePageButton();
@ -332,6 +344,36 @@ extension CommonOperations on WidgetTester {
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({
required String name,
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/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@ -147,7 +148,24 @@ extension Expectation on WidgetTester {
expect(textWidget, findsOneWidget);
}
/// Find the page name on the home page.
/// Find if the page is favorite
Finder findFavoritePageName(
String name, {
ViewLayoutPB layout = ViewLayoutPB.Document,
String? parentName,
ViewLayoutPB parentLayout = ViewLayoutPB.Document,
}) {
return find.byWidgetPredicate(
(widget) =>
widget is ViewItem &&
widget.view.isFavorite &&
widget.categoryType == FolderCategoryType.favorite &&
widget.view.name == name &&
widget.view.layout == layout,
skipOffstage: false,
);
}
Finder findPageName(
String name, {
ViewLayoutPB layout = ViewLayoutPB.Document,
@ -168,11 +186,11 @@ extension Expectation on WidgetTester {
of: find.byWidgetPredicate(
(widget) =>
widget is ViewItem &&
widget.view.name == name &&
widget.view.layout == layout,
widget.view.name == parentName &&
widget.view.layout == parentLayout,
skipOffstage: false,
),
matching: findPageName(name),
matching: findPageName(name, layout: layout),
);
}
}

View File

@ -35,4 +35,10 @@ class KVKeys {
/// The value is a json string with the following format:
/// {'viewId': true, 'viewId2': false}
static const String expandedViews = 'expandedViews';
/// The key for saving the expanded folder
///
/// The value is a json string with the following format:
/// {'SidebarFolderCategoryType.value': true}
static const String expandedFolders = 'expandedFolders';
}

View File

@ -15,7 +15,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
@ -155,11 +154,10 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
onSelected: (action, controller) async {
switch (action.inner) {
case _ActionType.viewDatabase:
getIt<MenuSharedState>().latestOpenView = viewPB;
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: viewPB.plugin(),
view: viewPB,
),
);
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/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
@ -109,10 +108,10 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
Log.error('Page($pageId) not found');
return;
}
getIt<MenuSharedState>().latestOpenView = view;
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
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_service.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/workspace/prelude.dart';
import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
@ -156,6 +157,7 @@ void _resolveFolderDeps(GetIt getIt) {
getIt.registerFactory<TrashBloc>(
() => TrashBloc(),
);
getIt.registerFactory<FavoriteBloc>(() => FavoriteBloc());
}
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 {
final result = await _workspaceService.createApp(
name: event.name,
desc: event.desc ?? "",
desc: event.desc,
index: 0, // default to the first index
);
result.fold(
(app) => emit(state.copyWith(plugin: app.plugin())),

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

View File

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

View File

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

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/plugins/trash/menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
@ -27,6 +28,7 @@ import 'package:styled_widget/styled_widget.dart';
import '../navigation.dart';
import 'app/create_button.dart';
import 'app/menu_app.dart';
import 'app/section/item.dart';
import 'menu_user.dart';
export './app/header/header.dart';
@ -56,6 +58,10 @@ class HomeMenu extends StatelessWidget {
return menuBloc;
},
),
BlocProvider(
create: (ctx) =>
getIt<FavoriteBloc>()..add(const FavoriteEvent.initial()),
)
],
child: MultiBlocListener(
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) {
return ExpandableTheme(
data: ExpandableThemeData(
@ -122,10 +172,16 @@ class HomeMenu extends StatelessWidget {
return ReorderableListView.builder(
itemCount: menuItems.length,
buildDefaultDragHandles: false,
header: Padding(
padding:
EdgeInsets.only(bottom: 20.0 - MenuAppSizes.appVPadding),
child: MenuUser(user),
header: Column(
children: [
Padding(
padding: EdgeInsets.only(
bottom: MenuAppSizes.appVPadding,
),
child: MenuUser(user),
),
_renderFavorites(context),
],
),
onReorder: (oldIndex, newIndex) {
// Moving item1 from index 0 to index 1
@ -180,7 +236,7 @@ class MenuSharedState {
ValueNotifier<ViewPB?> get notifier => _latestOpenView;
set latestOpenView(ViewPB? view) {
if (_latestOpenView.value != view) {
if (_latestOpenView.value?.id != view?.id) {
_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/startup/startup.dart';
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@ -11,7 +12,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PersonalFolder extends StatefulWidget {
class PersonalFolder extends StatelessWidget {
const PersonalFolder({
super.key,
required this.views,
@ -19,37 +20,49 @@ class PersonalFolder extends StatefulWidget {
final List<ViewPB> views;
@override
State<PersonalFolder> createState() => _PersonalFolderState();
}
class _PersonalFolderState extends State<PersonalFolder> {
bool isExpanded = true;
@override
Widget build(BuildContext context) {
return Column(
children: [
PersonalFolderHeader(
onPressed: () => setState(
() => isExpanded = !isExpanded,
),
onAdded: () => setState(() => isExpanded = true),
return BlocProvider<FolderBloc>(
create: (context) => FolderBloc(type: FolderCategoryType.personal)
..add(
const FolderEvent.initial(),
),
if (isExpanded)
...widget.views.map(
(view) => ViewItem(
key: ValueKey(view.id),
isFirstChild: view.id == widget.views.first.id,
view: view,
level: 0,
onSelected: (view) {
getIt<MenuSharedState>().latestOpenView = view;
context.read<MenuBloc>().add(MenuEvent.openPage(view.plugin()));
},
),
)
],
child: BlocBuilder<FolderBloc, FolderState>(
builder: (context, state) {
return Column(
children: [
PersonalFolderHeader(
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.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/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart';
@ -33,22 +34,43 @@ class HomeSideBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MenuBloc(
user: user,
workspace: workspaceSetting.workspace,
)..add(const MenuEvent.initial()),
child: BlocConsumer<MenuBloc, MenuState>(
builder: (context, state) => _buildSidebar(context, state),
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => MenuBloc(
user: user,
workspace: workspaceSetting.workspace,
)..add(const MenuEvent.initial()),
),
BlocProvider(
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
)
],
child: BlocListener<MenuBloc, MenuState>(
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
listener: (context, state) => getIt<TabsBloc>().add(
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;
return Container(
decoration: BoxDecoration(
@ -67,13 +89,13 @@ class HomeSideBar extends StatelessWidget {
const SidebarTopMenu(),
// user, setting
SidebarUser(user: user),
// Favorite, Not supported yet
const VSpace(20),
// scrollable document list
Expanded(
child: SingleChildScrollView(
child: SidebarFolder(
views: views,
favoriteViews: favoriteState.views,
),
),
),

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

View File

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

View File

@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@ -21,6 +23,7 @@ class ViewItem extends StatelessWidget {
const ViewItem({
super.key,
required this.view,
required this.categoryType,
required this.level,
this.leftPadding = 10,
required this.onSelected,
@ -30,6 +33,8 @@ class ViewItem extends StatelessWidget {
final ViewPB view;
final FolderCategoryType categoryType;
// indicate the level of the view item
// used to calculate the left padding
final int level;
@ -53,11 +58,10 @@ class ViewItem extends StatelessWidget {
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) {
view.childViews
..clear()
..addAll(state.childViews);
return InnerViewItem(
view: view,
view: state.view,
childViews: state.childViews,
categoryType: categoryType,
level: level,
leftPadding: leftPadding,
showActions: state.isEditing,
@ -76,6 +80,8 @@ class InnerViewItem extends StatelessWidget {
const InnerViewItem({
super.key,
required this.view,
required this.childViews,
required this.categoryType,
this.isDraggable = true,
this.isExpanded = true,
required this.level,
@ -86,6 +92,8 @@ class InnerViewItem extends StatelessWidget {
});
final ViewPB view;
final List<ViewPB> childViews;
final FolderCategoryType categoryType;
final bool isDraggable;
final bool isExpanded;
@ -108,11 +116,11 @@ class InnerViewItem extends StatelessWidget {
);
// if the view is expanded and has child views, render its child views
final childViews = view.childViews;
if (isExpanded && childViews.isNotEmpty) {
final children = childViews.map((childView) {
return ViewItem(
key: ValueKey(childView.id),
key: ValueKey('${categoryType.name} ${childView.id}'),
categoryType: categoryType,
isFirstChild: childView.id == childViews.first.id,
view: childView,
level: level + 1,
@ -139,12 +147,19 @@ class InnerViewItem extends StatelessWidget {
feedback: (context) {
return ViewItem(
view: view,
categoryType: categoryType,
level: level,
onSelected: onSelected,
isDraggable: false,
);
},
);
} else {
// keep the same height of the DraggableItem
child = Padding(
padding: const EdgeInsets.only(top: 2.0),
child: child,
);
}
return child;
@ -218,7 +233,8 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
children.add(_buildViewAddButton(context));
}
return GestureDetector(
// Don't use GestureDetector here, because it doesn't response to the tap event sometimes.
return InkWell(
onTap: () => widget.onSelected(widget.view),
child: SizedBox(
height: 26,
@ -284,10 +300,17 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
return Tooltip(
message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(),
child: ViewMoreActionButton(
view: widget.view,
onEditing: (value) =>
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
onAction: (action) {
switch (action) {
case ViewMoreActionType.favorite:
case ViewMoreActionType.unFavorite:
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view));
break;
case ViewMoreActionType.rename:
NavigatorTextFieldDialog(
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_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra/image.dart';
@ -6,26 +7,30 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
const supportedActionTypes = [
ViewMoreActionType.rename,
ViewMoreActionType.delete,
ViewMoreActionType.duplicate,
ViewMoreActionType.openInNewTab,
];
/// ··· button beside the view name
class ViewMoreActionButton extends StatelessWidget {
const ViewMoreActionButton({
super.key,
required this.view,
required this.onEditing,
required this.onAction,
});
final ViewPB view;
final void Function(bool value) onEditing;
final void Function(ViewMoreActionType) onAction;
@override
Widget build(BuildContext context) {
final supportedActionTypes = [
ViewMoreActionType.rename,
ViewMoreActionType.delete,
ViewMoreActionType.duplicate,
ViewMoreActionType.openInNewTab,
view.isFavorite
? ViewMoreActionType.unFavorite
: ViewMoreActionType.favorite,
];
return PopoverActionList<ViewMoreActionTypeWrapper>(
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 8),

View File

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

View File

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

View File

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

View File

@ -85,7 +85,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"anyhow",
"collab",
@ -888,7 +888,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"anyhow",
"bytes",
@ -906,7 +906,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"bytes",
"collab-sync",
@ -924,7 +924,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"anyhow",
"async-trait",
@ -951,7 +951,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"proc-macro2",
"quote",
@ -963,7 +963,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"anyhow",
"collab",
@ -982,7 +982,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"anyhow",
"chrono",
@ -1002,7 +1002,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"bincode",
"chrono",
@ -1022,7 +1022,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"anyhow",
"async-trait",
@ -1052,7 +1052,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=5783a5#5783a5ba6416125b669814d4089ed9afaf3469b5"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=f9df5b9#f9df5b9b5bf1e74c305aafcaf57b7b18493bded5"
dependencies = [
"bytes",
"collab",

View File

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

View File

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

View File

@ -1,11 +1,10 @@
use std::sync::{Arc, Weak};
use flowy_error::{FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
use crate::entities::*;
use crate::manager::FolderManager;
use crate::share::ImportParams;
use flowy_error::{FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
fn upgrade_folder(
folder_manager: AFPluginState<Weak<FolderManager>>,
@ -152,6 +151,18 @@ pub(crate) async fn delete_view_handler(
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(
data: AFPluginData<ViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>,
@ -208,6 +219,27 @@ pub(crate) async fn duplicate_view_handler(
Ok(())
}
#[tracing::instrument(level = "debug", skip(folder), err)]
pub(crate) async fn read_favorites_handler(
folder: AFPluginState<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)]
pub(crate) async fn read_trash_handler(
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::ImportData, import_data_handler)
.event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler)
.event(FolderEvent::ReadFavorites, read_favorites_handler)
.event(FolderEvent::ToggleFavorite, toggle_favorites_handler)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@ -133,7 +135,6 @@ pub enum FolderEvent {
#[event()]
GetFolderSnapshots = 31,
/// Moves a nested view to a new location in the hierarchy.
///
/// This function takes the `view_id` of the view to be moved,
@ -142,4 +143,10 @@ pub enum FolderEvent {
/// this specific view.
#[event(input = "MoveNestedViewPayloadPB")]
MoveNestedView = 32,
#[event(output = "RepeatedViewPB")]
ReadFavorites = 33,
#[event(input = "RepeatedViewIdPB")]
ToggleFavorite = 34,
}

View File

@ -7,8 +7,8 @@ use appflowy_integrate::{CollabPersistenceConfig, CollabType, RocksCollabDB};
use collab::core::collab::{CollabRawData, MutexCollab};
use collab::core::collab_state::SyncState;
use collab_folder::core::{
Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo, View, ViewChange,
ViewChangeReceiver, ViewLayout, Workspace,
FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo,
View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace,
};
use parking_lot::Mutex;
use tokio_stream::wrappers::WatchStream;
@ -370,9 +370,10 @@ impl FolderManager {
.await?;
}
let index = params.index;
let view = create_view(params, view_layout);
self.with_folder((), |folder| {
folder.insert_view(view.clone());
folder.insert_view(view.clone(), index);
});
Ok(view)
@ -393,7 +394,7 @@ impl FolderManager {
.await?;
let view = create_view(params, view_layout);
self.with_folder((), |folder| {
folder.insert_view(view.clone());
folder.insert_view(view.clone(), None);
});
Ok(view)
}
@ -442,21 +443,21 @@ impl FolderManager {
/// Move the view to trash. If the view is the current view, then set the current view to empty.
/// When the view is moved to trash, all the child views will be moved to trash as well.
/// All the favorite views being trashed will be unfavorited first to remove it from favorites list as well. The process of unfavoriting concerned view is handled by `unfavorite_view_and_decendants()`
#[tracing::instrument(level = "debug", skip(self), err)]
pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
self.with_folder((), |folder| {
let view = folder.views.get_view(view_id);
folder.add_trash(vec![view_id.to_string()]);
if let Some(view) = folder.views.get_view(view_id) {
self.unfavorite_view_and_decendants(view.clone(), &folder);
folder.add_trash(vec![view_id.to_string()]);
// notify the parent view that the view is moved to trash
send_notification(view_id, FolderNotification::DidMoveViewToTrash)
.payload(DeletedViewPB {
view_id: view_id.to_string(),
index: None,
})
.send();
// notify the parent view that the view is moved to trash
send_notification(view_id, FolderNotification::DidMoveViewToTrash)
.payload(DeletedViewPB {
view_id: view_id.to_string(),
index: None,
})
.send();
if let Some(view) = view {
notify_child_views_changed(
view_pb_without_child_views(view),
ChildViewChangeReason::DidDeleteView,
@ -467,6 +468,31 @@ impl FolderManager {
Ok(())
}
fn unfavorite_view_and_decendants(&self, view: Arc<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.
///
/// This function takes the `view_id` of the view to be moved,
@ -570,6 +596,7 @@ impl FolderManager {
.set_layout_if_not_none(params.layout)
.set_icon_url_if_not_none(params.icon_url)
.set_cover_url_if_not_none(params.cover_url)
.set_favorite_if_not_none(params.is_favorite)
.done()
});
@ -599,6 +626,14 @@ impl FolderManager {
let handler = self.get_handler(&view.layout)?;
let view_data = handler.duplicate_view(&view.id).await?;
// get the current view index in the parent view, because we need to insert the duplicated view below the current view.
let index = if let Some((_, __, views)) = self.get_view_relation(&view.parent_view_id).await {
views.iter().position(|id| id == view_id).map(|i| i as u32)
} else {
None
};
let duplicate_params = CreateViewParams {
parent_view_id: view.parent_view_id.clone(),
name: format!("{} (copy)", &view.name),
@ -608,9 +643,10 @@ impl FolderManager {
view_id: gen_view_id(),
meta: Default::default(),
set_as_current: true,
index,
};
let _ = self.create_view_with_params(duplicate_params).await?;
self.create_view_with_params(duplicate_params).await?;
Ok(())
}
@ -634,6 +670,57 @@ impl FolderManager {
self.get_view(&view_id).await.ok()
}
/// Toggles the favorite status of a view identified by `view_id`If the view is not a favorite, it will be added to the favorites list; otherwise, it will be removed from the list.
#[tracing::instrument(level = "debug", skip(self), err)]
pub async fn toggle_favorites(&self, view_id: &str) -> FlowyResult<()> {
self.with_folder((), |folder| {
if let Some(old_view) = folder.views.get_view(view_id) {
if old_view.is_favorite {
folder.delete_favorites(vec![view_id.to_string()]);
} else {
folder.add_favorites(vec![view_id.to_string()]);
}
}
});
self.send_toggle_favorite_notification(view_id).await;
Ok(())
}
// Used by toggle_favorites to send notification to frontend, after the favorite status of view has been changed.It sends two distinct notifications: one to correctly update the concerned view's is_favorite status, and another to update the list of favorites that is to be displayed.
async fn send_toggle_favorite_notification(&self, view_id: &str) {
if let Ok(view) = self.get_view(view_id).await {
let notification_type = if view.is_favorite {
FolderNotification::DidFavoriteView
} else {
FolderNotification::DidUnfavoriteView
};
send_notification("favorite", notification_type)
.payload(RepeatedViewPB {
items: vec![view.clone()],
})
.send();
send_notification(&view.id, FolderNotification::DidUpdateView)
.payload(view)
.send()
}
}
#[tracing::instrument(level = "trace", skip(self))]
pub(crate) async fn get_all_favorites(&self) -> Vec<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))]
pub(crate) async fn get_all_trash(&self) -> Vec<TrashInfo> {
self.with_folder(vec![], |folder| folder.get_all_trash())
@ -644,7 +731,6 @@ impl FolderManager {
self.with_folder((), |folder| {
folder.remote_all_trash();
});
send_notification("trash", FolderNotification::DidUpdateTrash)
.payload(RepeatedTrashPB { items: vec![] })
.send();
@ -718,11 +804,12 @@ impl FolderManager {
view_id,
meta: Default::default(),
set_as_current: false,
index: None,
};
let view = create_view(params, import_data.view_layout);
self.with_folder((), |folder| {
folder.insert_view(view.clone());
folder.insert_view(view.clone(), None);
});
notify_parent_view_did_change(self.mutex_folder.clone(), vec![view.parent_view_id.clone()]);
Ok(view)

View File

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

View File

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

View File

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

View File

@ -98,6 +98,7 @@ async fn update_parent_view_test() {
UpdateParentView {
name: Some(new_name.clone()),
desc: None,
is_favorite: None,
},
ReloadParentView(parent_view.id),
])
@ -143,6 +144,7 @@ async fn view_update() {
UpdateView {
name: Some(new_name.clone()),
desc: None,
is_favorite: None,
},
ReadView(view.id),
])
@ -249,6 +251,56 @@ async fn view_delete_all_permanent() {
assert_eq!(test.trash.len(), 0);
}
#[tokio::test]
async fn toggle_favorites() {
let mut test = FolderTest::new().await;
let view = test.child_view.clone();
test
.run_scripts(vec![
ReadView(view.id.clone()),
ToggleFavorite,
ReadFavorites,
ReadView(view.id.clone()),
])
.await;
assert_eq!(test.child_view.is_favorite, true);
assert!(test.favorites.len() != 0);
assert_eq!(test.favorites[0].id, view.id);
let view = test.child_view.clone();
test
.run_scripts(vec![
ReadView(view.id.clone()),
ToggleFavorite,
ReadFavorites,
ReadView(view.id.clone()),
])
.await;
assert!(!test.child_view.is_favorite);
assert!(test.favorites.is_empty());
}
#[tokio::test]
async fn delete_favorites() {
let mut test = FolderTest::new().await;
let view = test.child_view.clone();
test
.run_scripts(vec![
ReadView(view.id.clone()),
ToggleFavorite,
ReadFavorites,
ReadView(view.id.clone()),
])
.await;
assert_eq!(test.child_view.is_favorite, true);
assert!(test.favorites.len() != 0);
assert_eq!(test.favorites[0].id, view.id);
test.run_scripts(vec![DeleteView, ReadFavorites]).await;
assert!(test.favorites.len() == 0);
}
#[tokio::test]
async fn move_view_event_test() {
let mut test = FolderTest::new().await;

View File

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

View File

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

View File

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