feat: lock page (#7353)

* feat: lock page

* feat: add pageLockStatus bloc

* feat: add lock status and unlock status in title bar

* feat: add loading lock status

* feat: disable moveTo, delete, rename, updateIcon operations if the page is locked

* fix: lock toast issue

* feat: support locked database

* feat: support locked grid

* feat: support locked title

* feat: support locked board

* feat: support locked calendar
This commit is contained in:
Lucas 2025-02-12 09:49:36 +08:00 committed by GitHub
parent 552dba5abe
commit 71ce9affbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 812 additions and 155 deletions

View File

@ -17,6 +17,7 @@ import 'package:appflowy/shared/conditional_listenable_builder.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
@ -205,6 +206,7 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
onEditStateChanged: widget.onEditStateChanged,
focusScope: _focusScope,
boardController: _boardController,
view: widget.view,
),
),
),
@ -239,6 +241,7 @@ class _BoardContent extends StatefulWidget {
const _BoardContent({
required this.boardController,
required this.focusScope,
required this.view,
this.onEditStateChanged,
this.shrinkWrap = false,
});
@ -247,6 +250,7 @@ class _BoardContent extends StatefulWidget {
final BoardFocusScope focusScope;
final VoidCallback? onEditStateChanged;
final bool shrinkWrap;
final ViewPB view;
@override
State<_BoardContent> createState() => _BoardContentState();
@ -366,7 +370,7 @@ class _BoardContentState extends State<_BoardContent> {
scrollManager: scrollManager,
),
),
cardBuilder: (_, column, columnItem) => MultiBlocProvider(
cardBuilder: (context, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
@ -375,13 +379,24 @@ class _BoardContentState extends State<_BoardContent> {
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
BlocProvider(
create: (_) => ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
],
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
builder: (context, state) {
return IgnorePointer(
ignoring: state.isLocked,
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
),
);
},
),
),
),

View File

@ -1,9 +1,3 @@
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
@ -12,21 +6,26 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart';
import 'package:appflowy/plugins/database/calendar/application/unschedule_event_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../application/row/row_controller.dart';
import '../../widgets/row/row_detail.dart';
import 'calendar_day.dart';
import 'layout/sizes.dart';
import 'toolbar/calendar_setting_bar.dart';
@ -123,8 +122,18 @@ class _CalendarPageState extends State<CalendarPage> {
Widget build(BuildContext context) {
return CalendarControllerProvider(
controller: _eventController,
child: BlocProvider<CalendarBloc>.value(
value: _calendarBloc,
child: MultiBlocProvider(
providers: [
BlocProvider<CalendarBloc>.value(
value: _calendarBloc,
),
BlocProvider(
create: (context) => ViewLockStatusBloc(view: widget.view)
..add(
ViewLockStatusEvent.initial(),
),
),
],
child: MultiBlocListener(
listeners: [
BlocListener<CalendarBloc, CalendarState>(
@ -235,7 +244,21 @@ class _CalendarPageState extends State<CalendarPage> {
showBorder: false,
headerBuilder: _headerNavigatorBuilder,
weekDayBuilder: _headerWeekDayBuilder,
cellBuilder: _calendarDayBuilder,
cellBuilder: (
date,
calenderEvents,
isToday,
isInMonth,
position,
) =>
_calendarDayBuilder(
context,
date,
calenderEvents,
isToday,
isInMonth,
position,
),
useAvailableVerticalSpace: widget.shrinkWrap,
),
),
@ -344,6 +367,7 @@ class _CalendarPageState extends State<CalendarPage> {
}
Widget _calendarDayBuilder(
BuildContext context,
DateTime date,
List<CalendarEventData<CalendarDayEvent>> calenderEvents,
isToday,
@ -355,17 +379,21 @@ class _CalendarPageState extends State<CalendarPage> {
// is implemnted in the develop branch(WIP). Will be replaced with that.
final events = calenderEvents.map((value) => value.event!).toList()
..sort((a, b) => a.event.timestamp.compareTo(b.event.timestamp));
final isLocked = context.watch<ViewLockStatusBloc>().state.isLocked;
return CalendarDayCard(
viewId: widget.view.id,
isToday: isToday,
isInMonth: isInMonth,
events: events,
date: date,
rowCache: _calendarBloc.rowCache,
onCreateEvent: (date) =>
_calendarBloc.add(CalendarEvent.createEvent(date)),
position: position,
return IgnorePointer(
ignoring: isLocked,
child: CalendarDayCard(
viewId: widget.view.id,
isToday: isToday,
isInMonth: isInMonth,
events: events,
date: date,
rowCache: _calendarBloc.rowCache,
onCreateEvent: (date) =>
_calendarBloc.add(CalendarEvent.createEvent(date)),
position: position,
),
);
}

View File

@ -12,6 +12,7 @@ import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -138,8 +139,16 @@ class _GridPageState extends State<GridPage> {
@override
Widget build(BuildContext context) {
return BlocProvider<GridBloc>(
create: (_) => gridBloc,
return MultiBlocProvider(
providers: [
BlocProvider<GridBloc>(
create: (_) => gridBloc,
),
BlocProvider(
create: (context) => ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
],
child: BlocListener<ActionNavigationBloc, ActionNavigationState>(
listener: (context, state) {
final action = state.action;
@ -286,7 +295,10 @@ class _GridPageContentState extends State<GridPageContent> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_GridHeader(headerScrollController: headerScrollController),
_GridHeader(
headerScrollController: headerScrollController,
editable: !context.read<ViewLockStatusBloc>().state.isLocked,
),
_GridRows(
viewId: widget.view.id,
scrollController: _scrollController,
@ -298,18 +310,30 @@ class _GridPageContentState extends State<GridPageContent> {
}
class _GridHeader extends StatelessWidget {
const _GridHeader({required this.headerScrollController});
const _GridHeader({
required this.headerScrollController,
required this.editable,
});
final ScrollController headerScrollController;
final bool editable;
@override
Widget build(BuildContext context) {
return BlocBuilder<GridBloc, GridState>(
Widget child = BlocBuilder<GridBloc, GridState>(
builder: (_, state) => GridHeaderSliverAdaptor(
viewId: state.viewId,
anchorScrollController: headerScrollController,
),
);
if (!editable) {
child = IgnorePointer(
child: child,
);
}
return child;
}
}
@ -502,12 +526,21 @@ class _GridRowsState extends State<_GridRows> {
itemCount: itemCount,
itemBuilder: (context, index) {
if (index == itemCount - 1) {
return Column(
final child = Column(
key: const Key('grid_footer'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: footer,
);
if (context.read<ViewLockStatusBloc>().state.isLocked) {
return IgnorePointer(
key: const Key('grid_footer'),
child: child,
);
}
return child;
}
return _renderRow(
@ -542,6 +575,7 @@ class _GridRowsState extends State<_GridRows> {
rowId: rowId,
viewId: viewId,
index: index,
editable: !context.watch<ViewLockStatusBloc>().state.isLocked,
rowController: RowController(
viewId: viewId,
rowMeta: rowMeta,

View File

@ -1,27 +1,25 @@
import 'package:appflowy/plugins/database/domain/sort_service.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import "package:appflowy/generated/locale_keys.g.dart";
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/domain/sort_service.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../../../../widgets/row/accessory/cell_accessory.dart';
import '../../../../widgets/row/cells/cell_container.dart';
import '../../layout/sizes.dart';
import 'action.dart';
class GridRow extends StatelessWidget {
@ -35,6 +33,7 @@ class GridRow extends StatelessWidget {
required this.openDetailPage,
required this.index,
this.shrinkWrap = false,
required this.editable,
});
final FieldController fieldController;
@ -45,6 +44,7 @@ class GridRow extends StatelessWidget {
final void Function(BuildContext context) openDetailPage;
final int index;
final bool shrinkWrap;
final bool editable;
@override
Widget build(BuildContext context) {
@ -58,7 +58,7 @@ class GridRow extends StatelessWidget {
rowContent = Expanded(child: rowContent);
}
return BlocProvider(
rowContent = BlocProvider(
create: (_) => RowBloc(
fieldController: fieldController,
rowId: rowId,
@ -74,6 +74,14 @@ class GridRow extends StatelessWidget {
),
),
);
if (!editable) {
rowContent = IgnorePointer(
child: rowContent,
);
}
return rowContent;
}
}

View File

@ -79,13 +79,19 @@ class DatabaseTabBarView extends StatelessWidget {
..add(const DatabaseTabBarEvent.initial()),
),
BlocProvider<ViewBloc>(
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
create: (_) => ViewBloc(view: view)
..add(
const ViewEvent.initial(),
),
),
],
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (_, state) {
builder: (innerContext, state) {
final layout = state.tabBars[state.selectedIndex].layout;
return Column(
final isLocked =
context.read<ViewBloc?>()?.state.view.isLocked ?? false;
final Widget child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (UniversalPlatform.isMobile) const VSpace(12),
@ -99,9 +105,17 @@ class DatabaseTabBarView extends StatelessWidget {
return const SizedBox.shrink();
}
return UniversalPlatform.isDesktop
Widget child = UniversalPlatform.isDesktop
? const TabBarHeader()
: const MobileTabBarHeader();
if (innerContext.watch<ViewBloc>().state.view.isLocked) {
child = IgnorePointer(
child: child,
);
}
return child;
},
),
pageSettingBarExtensionFromState(context, state),
@ -111,6 +125,12 @@ class DatabaseTabBarView extends StatelessWidget {
),
],
);
if (isLocked) {
return IgnorePointer(child: child);
}
return child;
},
),
);

View File

@ -16,6 +16,7 @@ import 'package:appflowy/workspace/application/action_navigation/action_navigati
import 'package:appflowy/workspace/application/action_navigation/navigation_action.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_lock_status_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -63,6 +64,7 @@ class _DocumentPageState extends State<DocumentPage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
documentBloc.close();
super.dispose();
}
@ -82,31 +84,56 @@ class _DocumentPageState extends State<DocumentPage>
providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc),
BlocProvider.value(
value: ViewLockStatusBloc(view: widget.view)
..add(
ViewLockStatusEvent.initial(),
),
),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
buildWhen: shouldRebuildDocument,
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (prev, curr) => curr.isLocked != prev.isLocked,
listener: (context, lockStatusState) {
if (lockStatusState.isLoadingLockStatus) {
return;
}
editorState?.editable = !lockStatusState.isLocked;
},
builder: (context, lockStatusState) {
return BlocBuilder<DocumentBloc, DocumentState>(
buildWhen: shouldRebuildDocument,
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
final editorState = state.editorState;
this.editorState = editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return Center(child: AppFlowyErrorPage(error: error));
}
final editorState = state.editorState;
this.editorState = editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return Center(child: AppFlowyErrorPage(error: error));
}
if (state.forceClose) {
widget.onDeleted();
return const SizedBox.shrink();
}
if (state.forceClose) {
widget.onDeleted();
return const SizedBox.shrink();
}
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
child: buildEditorPage(context, state),
return BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
listener: (context, state) {
editorState.editable = !state.isLocked;
},
child:
BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
child: buildEditorPage(context, state),
),
);
},
);
},
),

View File

@ -14,6 +14,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -315,11 +316,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
);
final isViewDeleted = context.read<DocumentBloc>().state.isDeleted;
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final editor = Directionality(
textDirection: textDirection,
child: AppFlowyEditor(
editorState: widget.editorState,
editable: !isViewDeleted,
editable: !isViewDeleted && !isLocked,
editorScrollController: editorScrollController,
// setup the auto focus parameters
autoFocus: widget.autoFocus ?? autoFocus,

View File

@ -71,6 +71,11 @@ extension ViewExtension on ViewPB {
name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name;
bool get isDocument => pluginType == PluginType.document;
bool get isDatabase => [
PluginType.grid,
PluginType.board,
PluginType.calendar,
].contains(pluginType);
Widget defaultIcon({Size? size}) => FlowySvg(
switch (layout) {

View File

@ -0,0 +1,123 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'view_lock_status_bloc.freezed.dart';
class ViewLockStatusBloc
extends Bloc<ViewLockStatusEvent, ViewLockStatusState> {
ViewLockStatusBloc({
required this.view,
}) : viewBackendSvc = ViewBackendService(),
listener = ViewListener(viewId: view.id),
super(ViewLockStatusState.init(view)) {
on<ViewLockStatusEvent>(
(event, emit) async {
await event.when(
initial: () async {
listener.start(
onViewUpdated: (view) async {
add(ViewLockStatusEvent.updateLockStatus(view.isLocked));
},
);
final result = await ViewBackendService.getView(view.id);
final latestView = result.fold(
(view) => view,
(_) => view,
);
emit(
state.copyWith(
view: latestView,
isLocked: latestView.isLocked,
isLoadingLockStatus: false,
),
);
},
lock: () async {
final result = await ViewBackendService.lockView(view.id);
final isLocked = result.fold(
(_) => true,
(_) => false,
);
add(
ViewLockStatusEvent.updateLockStatus(
isLocked,
),
);
},
unlock: () async {
final result = await ViewBackendService.unlockView(view.id);
final isLocked = result.fold(
(_) => false,
(_) => true,
);
add(
ViewLockStatusEvent.updateLockStatus(
isLocked,
lockCounter: state.lockCounter + 1,
),
);
},
updateLockStatus: (isLocked, lockCounter) {
state.view.freeze();
final updatedView = state.view.rebuild(
(update) => update.isLocked = isLocked,
);
emit(
state.copyWith(
view: updatedView,
isLocked: isLocked,
lockCounter: lockCounter ?? state.lockCounter,
),
);
},
);
},
);
}
final ViewPB view;
final ViewBackendService viewBackendSvc;
final ViewListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
}
@freezed
class ViewLockStatusEvent with _$ViewLockStatusEvent {
const factory ViewLockStatusEvent.initial() = Initial;
const factory ViewLockStatusEvent.lock() = Lock;
const factory ViewLockStatusEvent.unlock() = Unlock;
const factory ViewLockStatusEvent.updateLockStatus(
bool isLocked, {
int? lockCounter,
}) = UpdateLockStatus;
}
@freezed
class ViewLockStatusState with _$ViewLockStatusState {
const factory ViewLockStatusState({
required ViewPB view,
required bool isLocked,
required int lockCounter,
@Default(true) bool isLoadingLockStatus,
}) = _ViewLockStatusState;
factory ViewLockStatusState.init(ViewPB view) => ViewLockStatusState(
view: view,
isLocked: false,
lockCounter: 0,
);
}

View File

@ -392,4 +392,14 @@ class ViewBackendService {
return (publishedPages.isNotEmpty, publishedPages);
}
static Future<FlowyResult<void, FlowyError>> lockView(String viewId) async {
final payload = ViewIdPB()..value = viewId;
return FolderEventLockView(payload).send();
}
static Future<FlowyResult<void, FlowyError>> unlockView(String viewId) async {
final payload = ViewIdPB()..value = viewId;
return FolderEventUnlockView(payload).send();
}
}

View File

@ -17,6 +17,14 @@ enum ViewMoreActionType {
divider,
lastModified,
created,
lockPage;
static const disableInLockedView = [
delete,
rename,
moveTo,
changeIcon,
];
}
extension ViewMoreActionTypeExtension on ViewMoreActionType {
@ -42,6 +50,8 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
return LocaleKeys.disclosureAction_changeIcon.tr();
case ViewMoreActionType.collapseAllPages:
return LocaleKeys.disclosureAction_collapseAllPages.tr();
case ViewMoreActionType.lockPage:
return LocaleKeys.disclosureAction_lockPage.tr();
case ViewMoreActionType.divider:
case ViewMoreActionType.lastModified:
case ViewMoreActionType.created:
@ -69,6 +79,8 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
return FlowySvgs.change_icon_s;
case ViewMoreActionType.collapseAllPages:
return FlowySvgs.collapse_all_page_s;
case ViewMoreActionType.lockPage:
return FlowySvgs.lock_page_s;
case ViewMoreActionType.divider:
case ViewMoreActionType.lastModified:
case ViewMoreActionType.copyLink:
@ -92,6 +104,7 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
case ViewMoreActionType.delete:
case ViewMoreActionType.lastModified:
case ViewMoreActionType.created:
case ViewMoreActionType.lockPage:
return const SizedBox.shrink();
}
}

View File

@ -21,6 +21,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.
import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart';
import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
@ -634,7 +635,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
? RawEmojiIconWidget(emoji: iconData, emojiSize: 16.0)
: Opacity(opacity: 0.6, child: widget.view.defaultIcon());
return AppFlowyPopover(
final Widget child = AppFlowyPopover(
offset: const Offset(20, 0),
controller: controller,
direction: PopoverDirection.rightWithCenterAligned,
@ -669,6 +670,14 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
);
},
);
if (widget.view.isLocked) {
return LockPageButtonWrapper(
child: child,
);
}
return child;
}
// > button or · button

View File

@ -5,6 +5,7 @@ import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@ -54,19 +55,22 @@ class ViewMoreActionPopover extends StatelessWidget {
List<ViewMoreActionTypeWrapper> _buildActionTypeWrappers() {
final actionTypes = _buildActionTypes();
return actionTypes
.map(
(e) => ViewMoreActionTypeWrapper(e, view, (controller, data) {
onEditing(false);
onAction(e, data);
bool enableClose = true;
if (data is SelectedEmojiIconResult) {
if (data.keepOpen) enableClose = false;
}
if (enableClose) controller.close();
}),
)
.toList();
return actionTypes.map(
(e) {
final actionWrapper =
ViewMoreActionTypeWrapper(e, view, (controller, data) {
onEditing(false);
onAction(e, data);
bool enableClose = true;
if (data is SelectedEmojiIconResult) {
if (data.keepOpen) enableClose = false;
}
if (enableClose) controller.close();
});
return actionWrapper;
},
).toList();
}
List<ViewMoreActionType> _buildActionTypes() {
@ -144,19 +148,30 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
PopoverController controller,
PopoverMutex? mutex,
) {
Widget child;
if (inner == ViewMoreActionType.divider) {
return _buildDivider();
child = _buildDivider();
} else if (inner == ViewMoreActionType.lastModified) {
return _buildLastModified(context);
child = _buildLastModified(context);
} else if (inner == ViewMoreActionType.created) {
return _buildCreated(context);
child = _buildCreated(context);
} else if (inner == ViewMoreActionType.changeIcon) {
return _buildEmojiActionButton(context, controller);
child = _buildEmojiActionButton(context, controller);
} else if (inner == ViewMoreActionType.moveTo) {
return _buildMoveToActionButton(context, controller);
child = _buildMoveToActionButton(context, controller);
} else {
child = _buildNormalActionButton(context, controller);
}
return _buildNormalActionButton(context, controller);
if (ViewMoreActionType.disableInLockedView.contains(inner) &&
sourceView.isLocked) {
child = LockPageButtonWrapper(
child: child,
);
}
return child;
}
Widget _buildNormalActionButton(

View File

@ -60,7 +60,7 @@ class _AccountDeletionButtonState extends State<AccountDeletionButton> {
const VSpace(8),
Row(
children: [
Flexible(
Expanded(
child: FlowyText.regular(
LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(),
fontSize: 12.0,
@ -69,7 +69,6 @@ class _AccountDeletionButtonState extends State<AccountDeletionButton> {
color: textColor,
),
),
const HSpace(32),
FlowyTextButton(
LocaleKeys.button_deleteAccount.tr(),
constraints: const BoxConstraints(minHeight: 32),

View File

@ -5,10 +5,12 @@ import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_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_lock_status_bloc.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@ -62,18 +64,23 @@ class _MoreViewActionsState extends State<MoreViewActions> {
);
}
Widget _buildPopup(ViewInfoState state) {
Widget _buildPopup(ViewInfoState viewInfoState) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
final actions = _buildActions(state);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
create: (_) => ViewBloc(view: widget.view)
..add(
const ViewEvent.initial(),
),
),
BlocProvider(
create: (_) => ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
BlocProvider(
create: (context) => SpaceBloc(
@ -84,27 +91,36 @@ class _MoreViewActionsState extends State<MoreViewActions> {
),
),
],
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
if (state.spaces.isEmpty &&
userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
return const SizedBox.shrink();
}
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, viewState) {
return BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
if (state.spaces.isEmpty &&
userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
return const SizedBox.shrink();
}
return ListView.builder(
key: ValueKey(state.spaces.hashCode),
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: actions.length,
physics: StyledScrollPhysics(),
itemBuilder: (_, index) => actions[index],
final actions = _buildActions(
context,
viewInfoState,
);
return ListView.builder(
key: ValueKey(state.spaces.hashCode),
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: actions.length,
physics: StyledScrollPhysics(),
itemBuilder: (_, index) => actions[index],
);
},
);
},
),
);
}
List<Widget> _buildActions(ViewInfoState state) {
List<Widget> _buildActions(BuildContext context, ViewInfoState state) {
final view = context.watch<ViewLockStatusBloc>().state.view;
final appearanceSettings = context.watch<AppearanceSettingsCubit>().state;
final dateFormat = appearanceSettings.dateFormat;
final timeFormat = appearanceSettings.timeFormat;
@ -122,14 +138,24 @@ class _MoreViewActionsState extends State<MoreViewActions> {
const FontSizeAction(),
ViewAction(
type: ViewMoreActionType.divider,
view: widget.view,
view: view,
mutex: popoverMutex,
),
],
if (widget.view.isDocument || widget.view.isDatabase) ...[
LockPageAction(
view: view,
),
ViewAction(
type: ViewMoreActionType.divider,
view: view,
mutex: popoverMutex,
),
],
...viewMoreActionTypes.map(
(type) => ViewAction(
type: type,
view: widget.view,
view: view,
mutex: popoverMutex,
),
),

View File

@ -0,0 +1,119 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LockPageAction extends StatefulWidget {
const LockPageAction({
super.key,
required this.view,
});
final ViewPB view;
@override
State<LockPageAction> createState() => _LockPageActionState();
}
class _LockPageActionState extends State<LockPageAction> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ViewLockStatusBloc(view: widget.view)
..add(
ViewLockStatusEvent.initial(),
),
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
builder: (context, state) {
return _buildTextButton(context);
},
),
);
}
Widget _buildTextButton(
BuildContext context,
) {
return Container(
height: 34,
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: FlowyIconTextButton(
margin: const EdgeInsets.symmetric(horizontal: 6),
onTap: () => _toggle(context),
leftIconBuilder: (onHover) => FlowySvg(
FlowySvgs.lock_page_s,
size: const Size.square(16.0),
),
iconPadding: 10.0,
textBuilder: (onHover) => FlowyText(
LocaleKeys.disclosureAction_lockPage.tr(),
figmaLineHeight: 18.0,
),
rightIconBuilder: (_) => _buildSwitch(
context,
),
),
);
}
Widget _buildSwitch(BuildContext context) {
final lockState = context.read<ViewLockStatusBloc>().state;
if (lockState.isLoadingLockStatus) {
return SizedBox.shrink();
}
return Container(
width: 30,
height: 20,
margin: const EdgeInsets.only(right: 6),
child: FittedBox(
fit: BoxFit.fill,
child: CupertinoSwitch(
value: lockState.isLocked,
activeTrackColor: Theme.of(context).colorScheme.primary,
onChanged: (_) => _toggle(context),
),
),
);
}
Future<void> _toggle(BuildContext context) async {
final isLocked = context.read<ViewLockStatusBloc>().state.isLocked;
context.read<ViewLockStatusBloc>().add(
isLocked ? ViewLockStatusEvent.unlock() : ViewLockStatusEvent.lock(),
);
Log.info('update page(${widget.view.id}) lock status: $isLocked');
}
}
class LockPageButtonWrapper extends StatelessWidget {
const LockPageButtonWrapper({
super.key,
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.lockPage_lockedOperationTooltip.tr(),
child: IgnorePointer(
child: Opacity(
opacity: 0.5,
child: child,
),
),
);
}
}

View File

@ -5,10 +5,12 @@ import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.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_lock_status_bloc.dart';
import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart';
import 'package:appflowy/workspace/application/view_title/view_title_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@ -29,8 +31,14 @@ class ViewTitleBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ViewTitleBarBloc(view: view),
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => ViewTitleBarBloc(view: view)),
BlocProvider(
create: (_) => ViewLockStatusBloc(view: view)
..add(const ViewLockStatusEvent.initial()),
),
],
child: BlocBuilder<ViewTitleBarBloc, ViewTitleBarState>(
builder: (context, state) {
final ancestors = state.ancestors;
@ -42,11 +50,14 @@ class ViewTitleBar extends StatelessWidget {
child: SizedBox(
height: 24,
child: Row(
children: _buildViewTitles(
context,
ancestors,
state.isDeleted,
),
children: [
..._buildViewTitles(
context,
ancestors,
state.isDeleted,
),
_buildLockPageStatus(context),
],
),
),
);
@ -55,6 +66,30 @@ class ViewTitleBar extends StatelessWidget {
);
}
Widget _buildLockPageStatus(BuildContext context) {
return BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (previous, current) =>
previous.isLoadingLockStatus == current.isLoadingLockStatus &&
current.isLoadingLockStatus == false,
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}
},
builder: (context, state) {
if (state.isLocked) {
return _Lock();
} else if (!state.isLocked && state.lockCounter > 0) {
return _ReLock();
}
return const SizedBox.shrink();
},
);
}
List<Widget> _buildViewTitles(
BuildContext context,
List<ViewPB> views,
@ -98,7 +133,7 @@ class ViewTitleBar extends StatelessWidget {
message: view.name,
child: ViewTitle(
view: view,
behavior: i == views.length - 1
behavior: i == views.length - 1 && !view.isLocked
? ViewTitleBehavior.editable // only the last one is editable
: ViewTitleBehavior.uneditable, // others are not editable
onUpdated: () {
@ -350,3 +385,78 @@ class _ViewTitleState extends State<ViewTitle> {
);
}
}
class _Lock extends StatelessWidget {
const _Lock();
@override
Widget build(BuildContext context) {
final color = const Color(0xFFD95A0B);
return FlowyTooltip(
message: LocaleKeys.lockPage_lockTooltip.tr(),
child: DecoratedBox(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(color: color),
borderRadius: BorderRadius.circular(6),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 4.0,
),
iconPadding: 4.0,
text: FlowyText.regular(
LocaleKeys.lockPage_lockPage.tr(),
color: color,
fontSize: 12.0,
),
hoverColor: color.withValues(alpha: 0.1),
leftIcon: FlowySvg(FlowySvgs.lock_page_s, color: color),
onTap: () => context.read<ViewLockStatusBloc>().add(
const ViewLockStatusEvent.unlock(),
),
),
),
);
}
}
class _ReLock extends StatelessWidget {
const _ReLock();
@override
Widget build(BuildContext context) {
final iconColor = const Color(0xFF8F959E);
return DecoratedBox(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(color: iconColor),
borderRadius: BorderRadius.circular(6),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 4.0,
),
iconPadding: 4.0,
text: FlowyText.regular(
LocaleKeys.lockPage_reLockPage.tr(),
fontSize: 12.0,
),
leftIcon: FlowySvg(
FlowySvgs.unlock_page_s,
color: iconColor,
blendMode: null,
),
onTap: () => context.read<ViewLockStatusBloc>().add(
const ViewLockStatusEvent.lock(),
),
),
);
}
}

View File

@ -90,8 +90,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "55c457f"
resolved-ref: "55c457f472ed997906bd77142ef94bb7f66cc629"
ref: "9b5c461"
resolved-ref: "9b5c46153a769affc9e5d85ff91115796e97bce8"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "5.0.0"

View File

@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an
your data. The best open source alternative to Notion.
publish_to: "none"
version: 0.8.2
version: 0.8.4
environment:
flutter: ">=3.27.4"
@ -178,7 +178,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "55c457f"
ref: "9b5c461"
appflowy_editor_plugins:
git:

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.75 10.5C1.75 8.73225 1.75 7.84838 2.29918 7.29919C2.84835 6.75 3.73223 6.75 5.5 6.75H10.5C12.2678 6.75 13.1516 6.75 13.7008 7.29919C14.25 7.84838 14.25 8.73225 14.25 10.5C14.25 12.2678 14.25 13.1516 13.7008 13.7008C13.1516 14.25 12.2678 14.25 10.5 14.25H5.5C3.73223 14.25 2.84835 14.25 2.29918 13.7008C1.75 13.1516 1.75 12.2678 1.75 10.5Z" stroke="#171711"/>
<path d="M4.25 6.75V5.5C4.25 3.42893 5.92893 1.75 8 1.75C10.0711 1.75 11.75 3.42893 11.75 5.5V6.75" stroke="#171711" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.75 10.5C1.75 8.73225 1.75 7.84838 2.29918 7.29919C2.84835 6.75 3.73223 6.75 5.5 6.75H10.5C12.2677 6.75 13.1516 6.75 13.7008 7.29919C14.25 7.84838 14.25 8.73225 14.25 10.5C14.25 12.2677 14.25 13.1516 13.7008 13.7008C13.1516 14.25 12.2677 14.25 10.5 14.25H5.5C3.73223 14.25 2.84835 14.25 2.29918 13.7008C1.75 13.1516 1.75 12.2677 1.75 10.5Z" fill="#8F959E" stroke="#8F959E"/>
<path d="M4.25 6.75V5.5C4.25 3.42893 5.92893 1.75 8 1.75C9.11064 1.75 10.1085 2.23281 10.7951 3" stroke="#8F959E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 628 B

View File

@ -174,7 +174,8 @@
"changeIcon": "Change icon",
"collapseAllPages": "Collapse all subpages",
"movePageTo": "Move page to",
"move": "Move"
"move": "Move",
"lockPage": "Lock page"
},
"blankPageTitle": "Blank page",
"newPageText": "New page",
@ -3096,5 +3097,12 @@
"settingsUpdateDescription": "Current version: {currentVersion} (Official build) → {newVersion}",
"settingsUpdateButton": "Update now",
"settingsUpdateWhatsNew": "What's new"
},
"lockPage": {
"lockPage": "Locked",
"reLockPage": "Re-lock",
"lockTooltip": "Page locked to prevent accidental editing. Click to unlock.",
"pageLockedToast": "Page locked. Editing is disabled until someone unlocks it.",
"lockedOperationTooltip": "Page locked to prevent accidental editing."
}
}

View File

@ -898,7 +898,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"arc-swap",
@ -923,7 +923,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"async-trait",
@ -963,7 +963,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"arc-swap",
@ -984,7 +984,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"bytes",
@ -1004,7 +1004,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"arc-swap",
@ -1026,7 +1026,7 @@ dependencies = [
[[package]]
name = "collab-importer"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"async-recursion",
@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"async-stream",
@ -1170,7 +1170,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=98463cc#98463cc764da69ef58e5cd894d1885ef4990bf9e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=68fd31892b2170568cae3f01206986fddd6d0988#68fd31892b2170568cae3f01206986fddd6d0988"
dependencies = [
"anyhow",
"collab",
@ -1400,7 +1400,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.11.2",
"phf 0.8.0",
"smallvec",
]
@ -4624,7 +4624,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
"phf_macros 0.8.0",
"phf_macros",
"phf_shared 0.8.0",
"proc-macro-hack",
]
@ -4644,7 +4644,6 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.2",
]
@ -4712,19 +4711,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.2",
"phf_shared 0.11.2",
"proc-macro2",
"quote",
"syn 2.0.94",
]
[[package]]
name = "phf_shared"
version = "0.8.0"

View File

@ -139,14 +139,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "98463cc" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "68fd31892b2170568cae3f01206986fddd6d0988" }
# Working directory: frontend
# To update the commit ID, run:

View File

@ -365,6 +365,9 @@ pub enum ErrorCode {
#[error("AI Max Required")]
AIMaxRequired = 125,
#[error("View is locked")]
ViewIsLocked = 126,
}
impl ErrorCode {

View File

@ -149,6 +149,8 @@ impl FlowyError {
static_flowy_error!(local_ai_unavailable, ErrorCode::LocalAIUnavailable);
static_flowy_error!(response_timeout, ErrorCode::ResponseTimeout);
static_flowy_error!(file_storage_limit, ErrorCode::FileStorageLimitExceeded);
static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked);
}
impl std::convert::From<ErrorCode> for FlowyError {

View File

@ -72,6 +72,11 @@ pub struct ViewPB {
// user_id
#[pb(index = 12, one_of)]
pub last_edited_by: Option<i64>,
// is_locked
// If true, the view is locked and cannot be edited.
#[pb(index = 13, one_of)]
pub is_locked: Option<bool>,
}
pub fn view_pb_without_child_views(view: View) -> ViewPB {
@ -88,6 +93,7 @@ pub fn view_pb_without_child_views(view: View) -> ViewPB {
created_by: view.created_by,
last_edited: view.last_edited_time,
last_edited_by: view.last_edited_by,
is_locked: view.is_locked,
}
}
@ -105,6 +111,7 @@ pub fn view_pb_without_child_views_from_arc(view: Arc<View>) -> ViewPB {
created_by: view.created_by,
last_edited: view.last_edited_time,
last_edited_by: view.last_edited_by,
is_locked: view.is_locked,
}
}
@ -126,6 +133,7 @@ pub fn view_pb_with_child_views(view: Arc<View>, child_views: Vec<Arc<View>>) ->
created_by: view.created_by,
last_edited: view.last_edited_time,
last_edited_by: view.last_edited_by,
is_locked: view.is_locked,
}
}

View File

@ -533,3 +533,25 @@ pub(crate) async fn remove_default_publish_view_handler(
folder.remove_default_published_view().await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(data, folder))]
pub(crate) async fn lock_view_handler(
data: AFPluginData<ViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>,
) -> Result<(), FlowyError> {
let folder = upgrade_folder(folder)?;
let view_id = data.into_inner().value;
folder.lock_view(&view_id).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip(data, folder))]
pub(crate) async fn unlock_view_handler(
data: AFPluginData<ViewIdPB>,
folder: AFPluginState<Weak<FolderManager>>,
) -> Result<(), FlowyError> {
let folder = upgrade_folder(folder)?;
let view_id = data.into_inner().value;
folder.unlock_view(&view_id).await?;
Ok(())
}

View File

@ -53,6 +53,8 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin {
.event(FolderEvent::GetDefaultPublishInfo, get_default_publish_info_handler)
.event(FolderEvent::SetDefaultPublishView, set_default_publish_view_handler)
.event(FolderEvent::RemoveDefaultPublishView, remove_default_publish_view_handler)
.event(FolderEvent::LockView, lock_view_handler)
.event(FolderEvent::UnlockView, unlock_view_handler)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@ -220,4 +222,10 @@ pub enum FolderEvent {
#[event()]
RemoveDefaultPublishView = 53,
#[event(input = "ViewIdPB")]
LockView = 54,
#[event(input = "ViewIdPB")]
UnlockView = 55,
}

View File

@ -767,6 +767,11 @@ impl FolderManager {
}
if let Some(view) = folder.get_view(view_id) {
// if the view is locked, the view can't be moved to trash
if view.is_locked.unwrap_or(false) {
return Err(FlowyError::view_is_locked());
}
Self::unfavorite_view_and_decendants(view.clone(), &mut folder);
folder.add_trash_view_ids(vec![view_id.to_string()]);
drop(folder);
@ -840,6 +845,11 @@ impl FolderManager {
let from_section = params.from_section;
let to_section = params.to_section;
let view = self.get_view_pb(&view_id).await?;
// if the view is locked, the view can't be moved
if view.is_locked.unwrap_or(false) {
return Err(FlowyError::view_is_locked());
}
let old_parent_id = view.parent_view_id;
if let Some(lock) = self.mutex_folder.load_full() {
let mut folder = lock.write().await;
@ -863,6 +873,12 @@ impl FolderManager {
#[tracing::instrument(level = "trace", skip(self), err)]
pub async fn move_view(&self, view_id: &str, from: usize, to: usize) -> FlowyResult<()> {
let workspace_id = self.user.workspace_id()?;
let view = self.get_view_pb(view_id).await?;
// if the view is locked, the view can't be moved
if view.is_locked.unwrap_or(false) {
return Err(FlowyError::view_is_locked());
}
if let Some((is_workspace, parent_view_id, child_views)) = self.get_view_relation(view_id).await
{
// The display parent view is the view that is displayed in the UI
@ -952,7 +968,7 @@ impl FolderManager {
#[tracing::instrument(level = "trace", skip(self), err)]
pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> {
self
.update_view(&params.view_id, |update| {
.update_view(&params.view_id, true, |update| {
update
.set_name_if_not_none(params.name)
.set_desc_if_not_none(params.desc)
@ -971,12 +987,34 @@ impl FolderManager {
params: UpdateViewIconParams,
) -> FlowyResult<()> {
self
.update_view(&params.view_id, |update| {
.update_view(&params.view_id, true, |update| {
update.set_icon(params.icon).done()
})
.await
}
/// Lock the view with the given view id.
///
/// If the view is locked, it cannot be edited.
#[tracing::instrument(level = "debug", skip(self), err)]
pub async fn lock_view(&self, view_id: &str) -> FlowyResult<()> {
self
.update_view(view_id, false, |update| {
update.set_page_lock_status(true).done()
})
.await
}
/// Unlock the view with the given view id.
#[tracing::instrument(level = "debug", skip(self), err)]
pub async fn unlock_view(&self, view_id: &str) -> FlowyResult<()> {
self
.update_view(view_id, false, |update| {
update.set_page_lock_status(false).done()
})
.await
}
/// Duplicate the view with the given view id.
///
/// Including the view data (icon, cover, extra) and the child views.
@ -1791,7 +1829,10 @@ impl FolderManager {
}
/// Update the view with the provided view_id using the specified function.
async fn update_view<F>(&self, view_id: &str, f: F) -> FlowyResult<()>
///
/// If the check_locked is true, it will check the lock status of the view. If the view is locked,
/// it will return an error.
async fn update_view<F>(&self, view_id: &str, check_locked: bool, f: F) -> FlowyResult<()>
where
F: FnOnce(ViewUpdate) -> Option<View>,
{
@ -1801,6 +1842,12 @@ impl FolderManager {
Some(lock) => {
let mut folder = lock.write().await;
let old_view = folder.get_view(view_id);
// Check if the view is locked
if check_locked && old_view.as_ref().and_then(|v| v.is_locked).unwrap_or(false) {
return Err(FlowyError::view_is_locked());
}
let new_view = folder.update_view(view_id, f);
Some((old_view, new_view))

View File

@ -164,6 +164,7 @@ pub(crate) fn create_view(uid: i64, params: CreateViewParams, layout: ViewLayout
last_edited_by: Some(uid),
extra: params.extra,
children: Default::default(),
is_locked: None,
}
}