feat: control access level in share menu (#8017)

* feat: use auto_confirm in share with user api

* feat: reduce share api calls

* feat: add team space section in title bar

* feat: only user with full access can invite guest/member

* feat: customize popup style

* feat: support section type

* feat: update access level based on public or private space

* test: add access level tests

* chore: update i18n

* chore: disable bloc talker

* fix: tests

* fix: general access test

* fix: view title bar test

* chore: i18n
This commit is contained in:
Lucas 2025-06-03 21:02:13 +08:00 committed by GitHub
parent 88a3e9eddd
commit 5598689dba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 707 additions and 210 deletions

View File

@ -32,13 +32,13 @@ enum ShareAccessLevel {
String get subtitle {
switch (this) {
case ShareAccessLevel.readOnly:
return 'Can\'t make changes';
return LocaleKeys.shareTab_cantMakeChanges.tr();
case ShareAccessLevel.readAndComment:
return 'Can make any changes';
return LocaleKeys.shareTab_canMakeAnyChanges.tr();
case ShareAccessLevel.readAndWrite:
return 'Can make any changes';
return LocaleKeys.shareTab_canMakeAnyChanges.tr();
case ShareAccessLevel.fullAccess:
return 'Can make any changes';
return LocaleKeys.shareTab_canMakeAnyChanges.tr();
}
}

View File

@ -4,6 +4,7 @@
/// - shared: the shared section is shared, anyone in the shared section can view/edit it.
/// - private: the shared section is private, only the users in the shared section can view/edit it.
enum SharedSectionType {
unknown,
public,
shared,
private;

View File

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'share_with_user_repository.dart';
@ -11,41 +12,47 @@ class LocalShareWithUserRepositoryImpl extends ShareWithUserRepository {
LocalShareWithUserRepositoryImpl();
final SharedUsers _sharedUsers = [
// current user has full access
SharedUser(
email: 'lucas.xu@appflowy.io',
name: 'Lucas Xu - Long long long long long name',
accessLevel: ShareAccessLevel.fullAccess,
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public',
),
// member user has read and write access
SharedUser(
email: 'vivian@appflowy.io',
name: 'Vivian Wang',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.guest,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/girl',
),
// member user has read access
SharedUser(
email: 'shuheng@appflowy.io',
name: 'Shuheng',
accessLevel: ShareAccessLevel.fullAccess,
role: ShareRole.owner,
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy',
),
// guest user has read access
SharedUser(
email: 'guest_user_1@appflowy.io',
name: 'Guest User 1 - Long long long long long name',
name: 'Read Only Guest',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/10',
),
// guest user has read and write access
SharedUser(
email: 'guest_user_2@appflowy.io',
name: 'Guest User 2',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.owner,
name: 'Read And Write Guest',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/11',
),
// Others
SharedUser(
email: 'member_user_1@appflowy.io',
name: 'Member User 1',
@ -157,4 +164,21 @@ class LocalShareWithUserRepositoryImpl extends ShareWithUserRepository {
return FlowySuccess(null);
}
@override
Future<FlowyResult<UserProfilePB, FlowyError>> getCurrentUserProfile() async {
// Simulate fetching current user profile
return FlowySuccess(
UserProfilePB()
..email = 'lucas.xu@appflowy.io'
..name = 'Lucas Xu',
);
}
@override
Future<FlowyResult<SharedSectionType, FlowyError>> getCurrentPageSectionType({
required String pageId,
}) async {
return FlowySuccess(SharedSectionType.private);
}
}

View File

@ -1,11 +1,15 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'share_with_user_repository.dart';
@ -70,6 +74,7 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository {
viewId: pageId,
emails: emails,
accessLevel: accessLevel.accessLevel,
autoConfirm: true, // TODO: remove this after the backend is ready
);
final result = await FolderEventSharePageWithUser(request).send();
@ -82,7 +87,9 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository {
return FlowySuccess(success);
},
(failure) {
Log.error('sharePageWithUser: $failure');
Log.error(
'share page($pageId) with users($emails) with access level($accessLevel): $failure',
);
return FlowyFailure(failure);
},
@ -124,4 +131,34 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository {
},
);
}
@override
Future<FlowyResult<UserProfilePB, FlowyError>> getCurrentUserProfile() async {
final result = await UserEventGetUserProfile().send();
return result;
}
@override
Future<FlowyResult<SharedSectionType, FlowyError>> getCurrentPageSectionType({
required String pageId,
}) async {
final request = ViewIdPB.create()..value = pageId;
final result = await FolderEventGetViewAncestors(request).send();
final ancestors = result.fold(
(s) => s.items,
(f) => <ViewPB>[],
);
final space = ancestors.firstWhereOrNull((e) => e.isSpace);
if (space == null) {
return FlowySuccess(SharedSectionType.unknown);
}
final sectionType = switch (space.spacePermission) {
SpacePermission.publicToAll => SharedSectionType.public,
SpacePermission.private => SharedSectionType.private,
};
return FlowySuccess(sectionType);
}
}

View File

@ -1,5 +1,6 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
/// Abstract repository for sharing with users.
@ -38,4 +39,12 @@ abstract class ShareWithUserRepository {
required String email,
required ShareRole role,
});
/// Get current user profile.
Future<FlowyResult<UserProfilePB, FlowyError>> getCurrentUserProfile();
/// Get current page is in public section or private section.
Future<FlowyResult<SharedSectionType, FlowyError>> getCurrentPageSectionType({
required String pageId,
});
}

View File

@ -6,7 +6,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
@ -50,24 +49,33 @@ class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
return;
}
final result = await UserBackendService.getCurrentUserProfile();
final result = await repository.getCurrentUserProfile();
final currentUser = result.fold(
(user) => user,
(error) => null,
);
final sectionTypeResult = await repository.getCurrentPageSectionType(
pageId: pageId,
);
final sectionType = sectionTypeResult.fold(
(type) => type,
(error) => SharedSectionType.unknown,
);
final shareLink = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: pageId,
);
final users = await _getLatestSharedUsersOrCurrentUsers();
final users = await _getSharedUsers();
emit(
state.copyWith(
currentUser: currentUser,
shareLink: shareLink,
users: users,
sectionType: sectionType,
),
);
}
@ -124,7 +132,7 @@ class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
final users = await _getSharedUsers();
emit(
state.copyWith(
@ -161,7 +169,7 @@ class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
final users = await _getSharedUsers();
emit(
state.copyWith(
removeResult: FlowySuccess(null),
@ -196,7 +204,7 @@ class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
final users = await _getSharedUsers();
emit(
state.copyWith(
updateAccessLevelResult: FlowySuccess(null),
@ -296,7 +304,7 @@ class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
final users = await _getSharedUsers();
emit(
state.copyWith(
turnIntoMemberResult: FlowySuccess(null),
@ -315,7 +323,7 @@ class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
);
}
Future<SharedUsers> _getLatestSharedUsersOrCurrentUsers() async {
Future<SharedUsers> _getSharedUsers() async {
final shareResult = await repository.getSharedUsersInPage(
pageId: pageId,
);

View File

@ -15,6 +15,7 @@ class ShareTabState {
this.shareLink = '',
this.generalAccessRole,
this.linkCopied = false,
this.sectionType = SharedSectionType.private,
this.initialResult,
this.shareResult,
this.removeResult,
@ -30,6 +31,7 @@ class ShareTabState {
final String shareLink;
final ShareAccessLevel? generalAccessRole;
final bool linkCopied;
final SharedSectionType sectionType;
final FlowyResult<void, FlowyError>? initialResult;
final FlowyResult<void, FlowyError>? shareResult;
final FlowyResult<void, FlowyError>? removeResult;
@ -45,6 +47,7 @@ class ShareTabState {
String? shareLink,
ShareAccessLevel? generalAccessRole,
bool? linkCopied,
SharedSectionType? sectionType,
FlowyResult<void, FlowyError>? initialResult,
FlowyResult<void, FlowyError>? shareResult,
FlowyResult<void, FlowyError>? removeResult,
@ -60,6 +63,7 @@ class ShareTabState {
shareLink: shareLink ?? this.shareLink,
generalAccessRole: generalAccessRole ?? this.generalAccessRole,
linkCopied: linkCopied ?? this.linkCopied,
sectionType: sectionType ?? this.sectionType,
initialResult: initialResult,
shareResult: shareResult,
removeResult: removeResult,
@ -80,6 +84,7 @@ class ShareTabState {
other.shareLink == shareLink &&
other.generalAccessRole == generalAccessRole &&
other.linkCopied == linkCopied &&
other.sectionType == sectionType &&
other.initialResult == initialResult &&
other.shareResult == shareResult &&
other.removeResult == removeResult &&
@ -98,6 +103,7 @@ class ShareTabState {
shareLink,
generalAccessRole,
linkCopied,
sectionType,
initialResult,
shareResult,
removeResult,
@ -108,6 +114,6 @@ class ShareTabState {
@override
String toString() {
return 'ShareTabState(currentUser: $currentUser, users: $users, availableUsers: $availableUsers, isLoading: $isLoading, errorMessage: $errorMessage, shareLink: $shareLink, generalAccessRole: $generalAccessRole, linkCopied: $linkCopied, initialResult: $initialResult, shareResult: $shareResult, removeResult: $removeResult, updateAccessLevelResult: $updateAccessLevelResult, turnIntoMemberResult: $turnIntoMemberResult)';
return 'ShareTabState(currentUser: $currentUser, users: $users, availableUsers: $availableUsers, isLoading: $isLoading, errorMessage: $errorMessage, shareLink: $shareLink, generalAccessRole: $generalAccessRole, shareSectionType: $SharedSectionType, linkCopied: $linkCopied, initialResult: $initialResult, shareResult: $shareResult, removeResult: $removeResult, updateAccessLevelResult: $updateAccessLevelResult, turnIntoMemberResult: $turnIntoMemberResult)';
}
}

View File

@ -5,11 +5,13 @@ import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widge
import 'package:appflowy/features/share_tab/presentation/widgets/general_access_section.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -66,35 +68,38 @@ class _ShareTabState extends State<ShareTab> {
return const SizedBox.shrink();
}
final currentUserRole = state.users
final currentUser = state.currentUser;
final accessLevel = state.users
.firstWhereOrNull(
(user) => user.email == state.currentUser?.email,
(user) => user.email == currentUser?.email,
)
?.role;
?.accessLevel;
final isFullAccess = accessLevel == ShareAccessLevel.fullAccess;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// share page with user by email
// hide this when the user is guest
if (currentUserRole != ShareRole.guest) ...[
VSpace(theme.spacing.l),
ShareWithUserWidget(
controller: controller,
onInvite: (emails) => _onSharePageWithUser(
context,
emails: emails,
accessLevel: ShareAccessLevel.readOnly,
),
// only user with full access can invite others
VSpace(theme.spacing.l),
ShareWithUserWidget(
controller: controller,
disabled: !isFullAccess,
onInvite: (emails) => _onSharePageWithUser(
context,
emails: emails,
accessLevel: ShareAccessLevel.readOnly,
),
],
),
// shared users
if (state.users.isNotEmpty) ...[
VSpace(theme.spacing.l),
PeopleWithAccessSection(
isInPublicPage: state.sectionType == SharedSectionType.public,
currentUserEmail: state.currentUser?.email ?? '',
users: state.users,
callbacks: _buildPeopleWithAccessSectionCallbacks(context),
@ -102,14 +107,16 @@ class _ShareTabState extends State<ShareTab> {
],
// general access
VSpace(theme.spacing.m),
GeneralAccessSection(
group: SharedGroup(
id: widget.workspaceId,
name: widget.workspaceName,
icon: widget.workspaceIcon,
if (state.sectionType == SharedSectionType.public) ...[
VSpace(theme.spacing.m),
GeneralAccessSection(
group: SharedGroup(
id: widget.workspaceId,
name: widget.workspaceName,
icon: widget.workspaceIcon,
),
),
),
],
// copy link
VSpace(theme.spacing.xl),
@ -150,6 +157,7 @@ class _ShareTabState extends State<ShareTab> {
},
onRemoveAccess: (user) {
// show a dialog to confirm the action when removing self access
final theme = AppFlowyTheme.of(context);
final shareTabBloc = context.read<ShareTabBloc>();
final removingSelf =
user.email == shareTabBloc.state.currentUser?.email;
@ -157,6 +165,9 @@ class _ShareTabState extends State<ShareTab> {
showConfirmDialog(
context: context,
title: 'Remove your own access',
titleStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
description: '',
style: ConfirmPopupStyle.cancelAndOk,
confirmLabel: 'Remove',
@ -186,19 +197,19 @@ class _ShareTabState extends State<ShareTab> {
controller.clear();
showToastNotification(
message: 'Invitation sent',
message: LocaleKeys.shareTab_invitationSent.tr(),
);
}, (error) {
String message;
switch (error.code) {
case ErrorCode.InvalidGuest:
message = 'The email is already in the list';
message = LocaleKeys.shareTab_emailAlreadyInList.tr();
break;
case ErrorCode.FreePlanGuestLimitExceeded:
message = 'Please upgrade to a Pro plan to invite more guests';
message = LocaleKeys.shareTab_upgradeToProToInviteGuests.tr();
break;
case ErrorCode.PaidPlanGuestLimitExceeded:
message = 'You have reached the maximum number of guests';
message = LocaleKeys.shareTab_maxGuestsReached.tr();
break;
default:
message = error.msg;
@ -214,7 +225,7 @@ class _ShareTabState extends State<ShareTab> {
if (removeResult != null) {
removeResult.fold((success) {
showToastNotification(
message: 'Removed guest successfully',
message: LocaleKeys.shareTab_removedGuestSuccessfully.tr(),
);
}, (error) {
showToastNotification(
@ -228,7 +239,21 @@ class _ShareTabState extends State<ShareTab> {
if (updateAccessLevelResult != null) {
updateAccessLevelResult.fold((success) {
showToastNotification(
message: 'Updated access level successfully',
message: LocaleKeys.shareTab_updatedAccessLevelSuccessfully.tr(),
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
final turnIntoMemberResult = state.turnIntoMemberResult;
if (turnIntoMemberResult != null) {
turnIntoMemberResult.fold((success) {
showToastNotification(
message: LocaleKeys.shareTab_turnedIntoMemberSuccessfully.tr(),
);
}, (error) {
showToastNotification(

View File

@ -122,7 +122,6 @@ class AccessLevelListWidget extends StatelessWidget {
selected: selectedAccessLevel == accessLevel,
leading: FlowySvg(
accessLevel.icon,
blendMode: null,
),
// Show a checkmark icon for the currently selected access level
trailing: selectedAccessLevel == accessLevel

View File

@ -39,7 +39,7 @@ class CopyLinkWidget extends StatelessWidget {
HSpace(theme.spacing.m),
Expanded(
child: Text(
'People above can access with the link',
LocaleKeys.shareTab_peopleAboveCanAccessWithTheLink.tr(),
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.primary,
),

View File

@ -1,6 +1,8 @@
import 'package:appflowy/features/share_tab/data/models/shared_group.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/shared_group_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class GeneralAccessSection extends StatelessWidget {
@ -15,7 +17,7 @@ class GeneralAccessSection extends StatelessWidget {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuSection(
title: 'General access',
title: LocaleKeys.shareTab_generalAccess.tr(),
padding: EdgeInsets.symmetric(
vertical: theme.spacing.xs,
horizontal: theme.spacing.m,

View File

@ -36,11 +36,13 @@ class PeopleWithAccessSection extends StatelessWidget {
super.key,
required this.currentUserEmail,
required this.users,
required this.isInPublicPage,
this.callbacks,
});
final String currentUserEmail;
final SharedUsers users;
final bool isInPublicPage;
final PeopleWithAccessSectionCallbacks? callbacks;
@override
@ -67,6 +69,7 @@ class PeopleWithAccessSection extends StatelessWidget {
return SharedUserWidget(
user: user,
currentUser: currentUser,
isInPublicPage: isInPublicPage,
callbacks: AccessLevelListCallbacks(
onRemoveAccess: () => callbacks?.onRemoveAccess.call(user),
onTurnIntoMember: () => callbacks?.onTurnIntoMember.call(user),

View File

@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:string_validator/string_validator.dart';
@ -10,10 +11,14 @@ class ShareWithUserWidget extends StatefulWidget {
super.key,
required this.onInvite,
this.controller,
this.disabled = false,
this.tooltip,
});
final void Function(List<String> emails) onInvite;
final TextEditingController? controller;
final void Function(List<String> emails) onInvite;
final bool disabled;
final String? tooltip;
@override
State<ShareWithUserWidget> createState() => _ShareWithUserWidgetState();
@ -44,7 +49,7 @@ class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
final Widget child = Row(
children: [
Expanded(
child: AFTextField(
@ -63,6 +68,18 @@ class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
),
],
);
if (widget.disabled) {
return FlowyTooltip(
message:
widget.tooltip ?? LocaleKeys.shareTab_onlyFullAccessCanInvite.tr(),
child: IgnorePointer(
child: child,
),
);
}
return child;
}
void _onTextChanged() {

View File

@ -34,22 +34,17 @@ class SharedGroupWidget extends StatelessWidget {
}
Widget _buildLeading(BuildContext context) {
return AFAvatar(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: WorkspaceIcon(
isEditable: false,
workspaceIcon: group.icon,
workspaceName: group.name,
iconSize: 32.0,
emojiSize: 24.0,
fontSize: 16.0,
onSelected: (r) {},
borderRadius: 8.0,
showBorder: false,
figmaLineHeight: 24.0,
),
),
return WorkspaceIcon(
isEditable: false,
workspaceIcon: group.icon,
workspaceName: group.name,
iconSize: 32.0,
emojiSize: 24.0,
fontSize: 16.0,
onSelected: (r) {},
borderRadius: 8.0,
showBorder: false,
figmaLineHeight: 24.0,
);
}

View File

@ -15,12 +15,14 @@ class SharedUserWidget extends StatelessWidget {
super.key,
required this.user,
required this.currentUser,
required this.isInPublicPage,
this.callbacks,
});
final SharedUser user;
final SharedUser currentUser;
final AccessLevelListCallbacks? callbacks;
final bool isInPublicPage;
@override
Widget build(BuildContext context) {
@ -96,68 +98,74 @@ class SharedUserWidget extends StatelessWidget {
);
}
Widget _buildTrailing(
BuildContext context,
) {
Widget _buildTrailing(BuildContext context) {
final isCurrentUser = user.email == currentUser.email;
final theme = AppFlowyTheme.of(context);
// Guest: read and write, read only
// Member: full access <- only have full access until the backend supports more access levels
// Owner: all access levels
final supportedAccessLevels = switch (user.role) {
ShareRole.guest => [
ShareAccessLevel.readOnly,
ShareAccessLevel.readAndWrite,
],
ShareRole.member => [ShareAccessLevel.fullAccess],
ShareRole.owner => [ShareAccessLevel.fullAccess],
};
final currentAccessLevel = currentUser.accessLevel;
Widget editAccessWidget;
Widget disabledAccessButton() => AFGhostTextButton.disabled(
text: user.accessLevel.title,
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
);
// The current guest user can't edit the access level of the other user
if (isCurrentUser && currentUser.role == ShareRole.guest) {
editAccessWidget = EditAccessLevelWidget(
selectedAccessLevel: user.accessLevel,
supportedAccessLevels: [],
additionalUserManagementOptions: [
AdditionalUserManagementOptions.removeAccess,
],
callbacks: callbacks ?? AccessLevelListCallbacks.none(),
);
} else if (currentUser.role == ShareRole.guest ||
user.role == ShareRole.member ||
user.role == ShareRole.owner) {
editAccessWidget = AFGhostTextButton.disabled(
text: user.accessLevel.title,
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
);
} else {
editAccessWidget = EditAccessLevelWidget(
selectedAccessLevel: user.accessLevel,
supportedAccessLevels: supportedAccessLevels,
additionalUserManagementOptions: [
AdditionalUserManagementOptions.removeAccess,
],
callbacks: callbacks ?? AccessLevelListCallbacks.none(),
);
Widget editAccessWidget(List<ShareAccessLevel> supported) =>
EditAccessLevelWidget(
selectedAccessLevel: user.accessLevel,
supportedAccessLevels: supported,
additionalUserManagementOptions: [
AdditionalUserManagementOptions.removeAccess,
],
callbacks: callbacks ?? AccessLevelListCallbacks.none(),
);
// In public page, member/owner permissions are fixed
if (isInPublicPage &&
(user.role == ShareRole.member || user.role == ShareRole.owner)) {
return disabledAccessButton();
}
if (user.role == ShareRole.guest) {
// Full access user can turn a guest into a member
if (user.role == ShareRole.guest &&
currentAccessLevel == ShareAccessLevel.fullAccess) {
return Row(
children: [
TurnIntoMemberWidget(
onTap: () {
callbacks?.onTurnIntoMember.call();
},
onTap: () => callbacks?.onTurnIntoMember.call(),
),
editAccessWidget,
editAccessWidget([
ShareAccessLevel.readOnly,
ShareAccessLevel.readAndWrite,
]),
],
);
}
return editAccessWidget;
// Self-management
if (isCurrentUser) {
if (currentAccessLevel == ShareAccessLevel.readOnly ||
currentAccessLevel == ShareAccessLevel.readAndWrite) {
// Can only remove self
return editAccessWidget([]);
} else if (currentAccessLevel == ShareAccessLevel.fullAccess) {
// Full access user cannot change own access
return disabledAccessButton();
}
}
// Managing others
if (currentAccessLevel == ShareAccessLevel.readOnly ||
currentAccessLevel == ShareAccessLevel.readAndWrite) {
// Cannot change others' access
return disabledAccessButton();
} else {
// Full access user can manage others
final supportedAccessLevels = [
ShareAccessLevel.readOnly,
ShareAccessLevel.readAndWrite,
];
return editAccessWidget(supportedAccessLevels);
}
}
}

View File

@ -1,5 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
class TurnIntoMemberWidget extends StatelessWidget {
@ -13,12 +16,15 @@ class TurnIntoMemberWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFGhostButton.normal(
onTap: onTap,
padding: EdgeInsets.all(theme.spacing.s),
builder: (context, isHovering, disabled) {
return FlowySvg(FlowySvgs.turn_into_member_m);
},
return FlowyTooltip(
message: LocaleKeys.shareTab_turnIntoMember.tr(),
child: AFGhostButton.normal(
onTap: onTap,
padding: EdgeInsets.all(theme.spacing.s),
builder: (context, isHovering, disabled) {
return FlowySvg(FlowySvgs.turn_into_member_m);
},
),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
@ -54,6 +55,7 @@ class AIChatPagePlugin extends Plugin {
}) : notifier = ViewPluginNotifier(view: view);
late final ViewInfoBloc _viewInfoBloc;
late final PageAccessLevelBloc _pageAccessLevelBloc;
late final _chatMessageSelectorBloc =
ChatSelectMessageBloc(viewNotifier: notifier);
@ -63,6 +65,7 @@ class AIChatPagePlugin extends Plugin {
@override
PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder(
viewInfoBloc: _viewInfoBloc,
pageAccessLevelBloc: _pageAccessLevelBloc,
chatMessageSelectorBloc: _chatMessageSelectorBloc,
notifier: notifier,
);
@ -77,11 +80,14 @@ class AIChatPagePlugin extends Plugin {
void init() {
_viewInfoBloc = ViewInfoBloc(view: notifier.view)
..add(const ViewInfoEvent.started());
_pageAccessLevelBloc = PageAccessLevelBloc(view: notifier.view)
..add(const PageAccessLevelEvent.initial());
}
@override
void dispose() {
_viewInfoBloc.close();
_pageAccessLevelBloc.close();
_chatMessageSelectorBloc.close();
notifier.dispose();
}
@ -91,11 +97,13 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
with NavigationItem {
AIChatPagePluginWidgetBuilder({
required this.viewInfoBloc,
required this.pageAccessLevelBloc,
required this.chatMessageSelectorBloc,
required this.notifier,
});
final ViewInfoBloc viewInfoBloc;
final PageAccessLevelBloc pageAccessLevelBloc;
final ChatSelectMessageBloc chatMessageSelectorBloc;
final ViewPluginNotifier notifier;
int? deletedViewIndex;
@ -104,8 +112,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
Widget get leftBarItem {
return BlocProvider.value(
value: pageAccessLevelBloc,
child: ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view),
);
}
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
@ -128,6 +140,7 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
providers: [
BlocProvider.value(value: chatMessageSelectorBloc),
BlocProvider.value(value: viewInfoBloc),
BlocProvider.value(value: pageAccessLevelBloc),
],
child: AIChatPage(
userProfile: context.userProfile!,

View File

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
@ -345,6 +346,7 @@ class DatabaseTabBarViewPlugin extends Plugin {
final PluginType _pluginType;
late final ViewInfoBloc _viewInfoBloc;
late final PageAccessLevelBloc _pageAccessLevelBloc;
/// Used to open a Row on plugin load
///
@ -353,6 +355,7 @@ class DatabaseTabBarViewPlugin extends Plugin {
@override
PluginWidgetBuilder get widgetBuilder => DatabasePluginWidgetBuilder(
bloc: _viewInfoBloc,
pageAccessLevelBloc: _pageAccessLevelBloc,
notifier: notifier,
initialRowId: initialRowId,
);
@ -367,11 +370,14 @@ class DatabaseTabBarViewPlugin extends Plugin {
void init() {
_viewInfoBloc = ViewInfoBloc(view: notifier.view)
..add(const ViewInfoEvent.started());
_pageAccessLevelBloc = PageAccessLevelBloc(view: notifier.view)
..add(const PageAccessLevelEvent.initial());
}
@override
void dispose() {
_viewInfoBloc.close();
_pageAccessLevelBloc.close();
notifier.dispose();
}
}
@ -398,11 +404,13 @@ class DatabasePluginWidgetBuilderSize {
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
DatabasePluginWidgetBuilder({
required this.bloc,
required this.pageAccessLevelBloc,
required this.notifier,
this.initialRowId,
});
final ViewInfoBloc bloc;
final PageAccessLevelBloc pageAccessLevelBloc;
final ViewPluginNotifier notifier;
/// Used to open a Row on plugin load
@ -413,8 +421,12 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
Widget get leftBarItem {
return BlocProvider.value(
value: pageAccessLevelBloc,
child: ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view),
);
}
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
@ -464,8 +476,15 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
@override
Widget? get rightBarItem {
final view = notifier.view;
return BlocProvider<ViewInfoBloc>.value(
value: bloc,
return MultiBlocProvider(
providers: [
BlocProvider<ViewInfoBloc>.value(
value: bloc,
),
BlocProvider<PageAccessLevelBloc>.value(
value: pageAccessLevelBloc,
),
],
child: Row(
children: [
ShareButton(key: ValueKey(view.id), view: view),

View File

@ -1,5 +1,6 @@
library;
import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
@ -60,6 +61,7 @@ class DocumentPlugin extends Plugin {
late PluginType _pluginType;
late final ViewInfoBloc _viewInfoBloc;
late final PageAccessLevelBloc _pageAccessLevelBloc;
@override
final ViewPluginNotifier notifier;
@ -73,6 +75,7 @@ class DocumentPlugin extends Plugin {
@override
PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder(
bloc: _viewInfoBloc,
pageAccessLevelBloc: _pageAccessLevelBloc,
notifier: notifier,
initialSelection: initialSelection,
initialBlockId: initialBlockId,
@ -88,11 +91,14 @@ class DocumentPlugin extends Plugin {
void init() {
_viewInfoBloc = ViewInfoBloc(view: notifier.view)
..add(const ViewInfoEvent.started());
_pageAccessLevelBloc = PageAccessLevelBloc(view: notifier.view)
..add(const PageAccessLevelEvent.initial());
}
@override
void dispose() {
_viewInfoBloc.close();
_pageAccessLevelBloc.close();
notifier.dispose();
}
}
@ -104,10 +110,12 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
required this.notifier,
this.initialSelection,
this.initialBlockId,
required this.pageAccessLevelBloc,
});
final ViewInfoBloc bloc;
final ViewPluginNotifier notifier;
final PageAccessLevelBloc pageAccessLevelBloc;
ViewPB get view => notifier.view;
int? deletedViewIndex;
@ -139,8 +147,15 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
PickerTabType.custom,
];
return BlocProvider<ViewInfoBloc>.value(
value: bloc,
return MultiBlocProvider(
providers: [
BlocProvider<ViewInfoBloc>.value(
value: bloc,
),
BlocProvider<PageAccessLevelBloc>.value(
value: pageAccessLevelBloc,
),
],
child: BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
builder: (_, state) => DocumentPage(
key: ValueKey(view.id),
@ -159,7 +174,12 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem => ViewTitleBar(key: ValueKey(view.id), view: view);
Widget get leftBarItem {
return BlocProvider.value(
value: pageAccessLevelBloc,
child: ViewTitleBar(key: ValueKey(view.id), view: view),
);
}
@override
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
@ -167,8 +187,15 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
@override
Widget? get rightBarItem {
return BlocProvider<ViewInfoBloc>.value(
value: bloc,
return MultiBlocProvider(
providers: [
BlocProvider<ViewInfoBloc>.value(
value: bloc,
),
BlocProvider<PageAccessLevelBloc>.value(
value: pageAccessLevelBloc,
),
],
child: Row(
mainAxisSize: MainAxisSize.min,
children: [

View File

@ -87,10 +87,6 @@ class _DocumentPageState extends State<DocumentPage>
providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc),
BlocProvider.value(
value: PageAccessLevelBloc(view: widget.view)
..add(PageAccessLevelEvent.initial()),
),
BlocProvider(
create: (context) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),

View File

@ -1,4 +1,4 @@
import 'package:appflowy/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart';
import 'package:appflowy/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart';
import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart';
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@ -44,7 +44,8 @@ class ShareButton extends StatelessWidget {
),
BlocProvider(
create: (context) => ShareTabBloc(
repository: RustShareWithUserRepositoryImpl(),
// repository: RustShareWithUserRepositoryImpl(),
repository: LocalShareWithUserRepositoryImpl(),
pageId: view.id,
workspaceId: workspaceId,
)..add(ShareTabEvent.initialize()),

View File

@ -25,15 +25,13 @@ class DebugTask extends LaunchTask {
Bloc.observer = TalkerBlocObserver(
talker: talker,
settings: TalkerBlocLoggerSettings(
// Disabled by default to prevent mixing with AppFlowy logs
// Enable to observe all bloc events
enabled: false,
printEventFullData: false,
printStateFullData: false,
printChanges: true,
printClosings: true,
printCreations: true,
transitionFilter: (_, transition) {
transitionFilter: (bloc, transition) {
// By default, observe all transitions
// You can add your own filter here if needed
// when you want to observer a specific bloc

View File

@ -280,6 +280,8 @@ class ConfirmPopup extends StatefulWidget {
required this.onConfirm,
this.onCancel,
this.confirmLabel,
this.titleStyle,
this.descriptionStyle,
this.confirmButtonColor,
this.confirmButtonBuilder,
this.child,
@ -289,7 +291,9 @@ class ConfirmPopup extends StatefulWidget {
});
final String title;
final TextStyle? titleStyle;
final String description;
final TextStyle? descriptionStyle;
final VoidCallback onConfirm;
final VoidCallback? onCancel;
final Color? confirmButtonColor;
@ -392,9 +396,10 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
Expanded(
child: Text(
widget.title,
style: theme.textStyle.heading4.prominent(
color: ConfirmPopupColor.titleColor(context),
),
style: widget.titleStyle ??
theme.textStyle.heading4.prominent(
color: ConfirmPopupColor.titleColor(context),
),
overflow: TextOverflow.ellipsis,
),
),
@ -423,9 +428,10 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
return Text(
widget.description,
style: theme.textStyle.body.standard(
color: ConfirmPopupColor.descriptionColor(context),
),
style: widget.descriptionStyle ??
theme.textStyle.body.standard(
color: ConfirmPopupColor.descriptionColor(context),
),
maxLines: 5,
);
}

View File

@ -602,6 +602,8 @@ Future<void> showConfirmDialog({
required BuildContext context,
required String title,
required String description,
TextStyle? titleStyle,
TextStyle? descriptionStyle,
VoidCallback? onConfirm,
VoidCallback? onCancel,
String? confirmLabel,
@ -620,6 +622,8 @@ Future<void> showConfirmDialog({
child: ConfirmPopup(
title: title,
description: description,
titleStyle: titleStyle,
descriptionStyle: descriptionStyle,
confirmButtonBuilder: confirmButtonBuilder,
onConfirm: () => onConfirm?.call(),
onCancel: () => onCancel?.call(),

View File

@ -1,10 +1,10 @@
import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart';
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/features/workspace/logic/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_info/view_info_bloc.dart';
@ -79,10 +79,6 @@ class _MoreViewActionsState extends State<MoreViewActions> {
const ViewEvent.initial(),
),
),
BlocProvider(
create: (_) => PageAccessLevelBloc(view: widget.view)
..add(PageAccessLevelEvent.initial()),
),
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
@ -91,6 +87,9 @@ class _MoreViewActionsState extends State<MoreViewActions> {
const SpaceEvent.initial(openFirstPage: false),
),
),
BlocProvider.value(
value: context.read<PageAccessLevelBloc>(),
),
],
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, viewState) {
@ -124,6 +123,7 @@ class _MoreViewActionsState extends State<MoreViewActions> {
final pageAccessLevelBloc = context.watch<PageAccessLevelBloc>();
final pageAccessLevelState = pageAccessLevelBloc.state;
final view = pageAccessLevelState.view;
final appearanceSettings = context.watch<AppearanceSettingsCubit>().state;
final dateFormat = appearanceSettings.dateFormat;
final timeFormat = appearanceSettings.timeFormat;

View File

@ -26,16 +26,10 @@ class LockPageAction extends StatefulWidget {
class _LockPageActionState extends State<LockPageAction> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => PageAccessLevelBloc(view: widget.view)
..add(
PageAccessLevelEvent.initial(),
),
child: BlocBuilder<PageAccessLevelBloc, PageAccessLevelState>(
builder: (context, state) {
return _buildTextButton(context);
},
),
return BlocBuilder<PageAccessLevelBloc, PageAccessLevelState>(
builder: (context, state) {
return _buildTextButton(context);
},
);
}

View File

@ -1,10 +1,13 @@
import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart';
import 'package:appflowy/features/share_tab/data/models/share_section_type.dart';
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@ -14,7 +17,9 @@ 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:appflowy_backend/protobuf/flowy-folder/view.pb.dart'
hide AFRolePB;
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@ -42,10 +47,6 @@ class ViewTitleBar extends StatelessWidget {
view: view,
),
),
BlocProvider(
create: (_) => PageAccessLevelBloc(view: view)
..add(const PageAccessLevelEvent.initial()),
),
],
child: BlocBuilder<PageAccessLevelBloc, PageAccessLevelState>(
buildWhen: (previous, current) =>
@ -66,10 +67,11 @@ class ViewTitleBar extends StatelessWidget {
SpacePermission.publicToAll => SharedSectionType.public,
SpacePermission.private => SharedSectionType.private,
};
if (!context.read<PageAccessLevelBloc>().state.isShared) {
context.read<PageAccessLevelBloc>().add(
PageAccessLevelEvent.updateSectionType(sectionType),
);
final bloc = context.read<PageAccessLevelBloc>();
if (!bloc.isClosed && !bloc.state.isShared) {
bloc.add(
PageAccessLevelEvent.updateSectionType(sectionType),
);
}
},
builder: (context, state) {
@ -153,6 +155,13 @@ class ViewTitleBar extends StatelessWidget {
return [];
}
// remove the space from views if the current user role is a guest
final myRole =
context.read<UserWorkspaceBloc>().state.currentWorkspace?.role;
if (myRole == AFRolePB.Guest) {
views = views.where((view) => !view.isSpace).toList();
}
// ignore the workspace name, use section name instead in the future
// skip the workspace view
for (var i = 1; i < views.length; i++) {
@ -237,6 +246,8 @@ class ViewTitleBar extends StatelessWidget {
SharedSectionType.public => FlowySvgs.public_section_icon_m,
SharedSectionType.private => FlowySvgs.private_section_icon_m,
SharedSectionType.shared => FlowySvgs.shared_section_icon_m,
SharedSectionType.unknown =>
throw UnsupportedError('Unknown section type'),
};
final icon = FlowySvg(
@ -249,22 +260,37 @@ class ViewTitleBar extends StatelessWidget {
SharedSectionType.public => 'Team space',
SharedSectionType.private => 'Private',
SharedSectionType.shared => 'Shared',
SharedSectionType.unknown =>
throw UnsupportedError('Unknown section type'),
};
return Row(
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
HSpace(theme.spacing.xs),
icon,
const HSpace(4.0), // ask designer to provide the spacing
Text(
text,
style: theme.textStyle.caption
.enhanced(color: theme.textColorScheme.tertiary),
),
HSpace(theme.spacing.xs),
],
final workspaceName =
context.read<UserWorkspaceBloc>().state.currentWorkspace?.name;
final tooltipText = switch (pageAccessLevelState.sectionType) {
SharedSectionType.public => 'Everyone at $workspaceName has access',
SharedSectionType.private => 'Only you have access',
SharedSectionType.shared => '',
SharedSectionType.unknown =>
throw UnsupportedError('Unknown section type'),
};
return FlowyTooltip(
message: tooltipText,
child: Row(
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
HSpace(theme.spacing.xs),
icon,
const HSpace(4.0), // ask designer to provide the spacing
Text(
text,
style: theme.textStyle.caption
.enhanced(color: theme.textColorScheme.tertiary),
),
HSpace(theme.spacing.xs),
],
),
);
}
}
@ -342,8 +368,10 @@ class _ViewTitleState extends State<ViewTitle> {
final isEditable = widget.behavior == ViewTitleBehavior.editable;
return BlocProvider(
create: (_) =>
ViewTitleBloc(view: widget.view)..add(const ViewTitleEvent.initial()),
create: (_) => ViewTitleBloc(view: widget.view)
..add(
const ViewTitleEvent.initial(),
),
child: BlocConsumer<ViewTitleBloc, ViewTitleState>(
listenWhen: (previous, current) {
if (previous.view == null || current.view == null) {
@ -440,19 +468,23 @@ class _ViewTitleState extends State<ViewTitle> {
ViewTitleState state,
bool isEditable,
) {
final spaceIcon = state.view?.buildSpaceIconSvg(context);
final view = state.view ?? widget.view;
final spaceIcon = view.buildSpaceIconSvg(context);
final icon =
state.icon.isNotEmpty ? state.icon : view.icon.toEmojiIconData();
final name = state.name.isEmpty ? widget.view.name : state.name;
return SingleChildScrollView(
child: Row(
children: [
if (state.icon.isNotEmpty) ...[
RawEmojiIconWidget(emoji: state.icon, emojiSize: 14.0),
if (icon.isNotEmpty) ...[
RawEmojiIconWidget(emoji: icon, emojiSize: 14.0),
const HSpace(4.0),
],
if (state.view?.isSpace == true && spaceIcon != null) ...[
if (view.isSpace && spaceIcon != null) ...[
SpaceIcon(
dimension: 14,
svgSize: 8.5,
space: state.view!,
space: view,
cornerRadius: 4,
),
const HSpace(6.0),
@ -460,9 +492,7 @@ class _ViewTitleState extends State<ViewTitle> {
Opacity(
opacity: isEditable ? 1.0 : 0.5,
child: FlowyText.regular(
state.name.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: state.name,
name.orDefault(LocaleKeys.menuAppHeader_defaultNewPageName.tr()),
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 18.0,

View File

@ -2181,26 +2181,26 @@ packages:
dependency: "direct main"
description:
name: talker
sha256: "45abef5b92f9b9bd42c3f20133ad4b20ab12e1da2aa206fc0a40ea874bed7c5d"
sha256: f4b3f6110b03f78ef314f897322e47c6be2b0fbe6fafa62e4041ac5321e88620
url: "https://pub.dev"
source: hosted
version: "4.7.1"
version: "4.8.1"
talker_bloc_logger:
dependency: "direct main"
description:
name: talker_bloc_logger
sha256: "2214a5f6ef9ff33494dc6149321c270356962725cc8fc1a485d44b1d9b812ddd"
sha256: "2f3ccf88c473105b7fecc4a81289f4345ee5aa652dd2f43108a60dded08afc4a"
url: "https://pub.dev"
source: hosted
version: "4.7.1"
version: "4.8.1"
talker_logger:
dependency: transitive
description:
name: talker_logger
sha256: ed9b20b8c09efff9f6b7c63fc6630ee2f84aa92661ae09e5ba04e77272bf2ad2
sha256: "4e526350aa917d8c68eeded19604ce82ffe68ceeb9fd803225d30a12924ca506"
url: "https://pub.dev"
source: hosted
version: "4.7.1"
version: "4.8.1"
term_glyph:
dependency: transitive
description:

View File

@ -147,7 +147,7 @@ dependencies:
xml: ^6.5.0
window_manager: ^0.4.3
saver_gallery: ^4.0.1
talker_bloc_logger: ^4.7.1
talker_bloc_logger: ^4.8.1
talker: ^4.7.1
analyzer: 6.11.0

View File

@ -1,5 +1,7 @@
import 'package:appflowy/features/share_tab/data/models/shared_group.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/general_access_section.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../widget_test_wrapper.dart';
@ -19,7 +21,7 @@ void main() {
),
),
);
expect(find.text('General access'), findsOneWidget);
expect(find.text(LocaleKeys.shareTab_generalAccess.tr()), findsOneWidget);
expect(find.byType(GeneralAccessSection), findsOneWidget);
});
});

View File

@ -18,6 +18,7 @@ void main() {
await tester.pumpWidget(
WidgetTestWrapper(
child: PeopleWithAccessSection(
isInPublicPage: true,
currentUserEmail: user.email,
users: [user],
callbacks: PeopleWithAccessSectionCallbacks(

View File

@ -1,6 +1,10 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/shared_user_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/turn_into_member_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -20,6 +24,7 @@ void main() {
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: true,
user: user,
currentUser: user,
),
@ -42,6 +47,7 @@ void main() {
WidgetTestWrapper(
child: IntrinsicWidth(
child: SharedUserWidget(
isInPublicPage: true,
user: user,
currentUser: user,
),
@ -50,5 +56,248 @@ void main() {
);
expect(find.text(LocaleKeys.shareTab_guest.tr()), findsOneWidget);
});
testWidgets('readonly user can only see remove self action in menu',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Readonly User',
email: 'readonly@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: user.copyWith(accessLevel: ShareAccessLevel.readOnly),
),
),
);
// Tap the EditAccessLevelWidget to open the menu
await tester.tap(find.byType(EditAccessLevelWidget));
await tester.pumpAndSettle();
// Only remove access should be visible as an actionable item
expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget);
});
testWidgets('edit user can only see remove self action in menu',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Edit User',
email: 'edit@user.com',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: user.copyWith(
accessLevel: ShareAccessLevel.readAndWrite,
),
),
),
);
// Tap the EditAccessLevelWidget to open the menu
await tester.tap(find.byType(EditAccessLevelWidget));
await tester.pumpAndSettle();
// Only remove access should be visible as an actionable item
expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget);
});
testWidgets('full access user can change another people permission',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Other User',
email: 'other@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
);
final currentUser = SharedUser(
name: 'Full Access User',
email: 'full@user.com',
accessLevel: ShareAccessLevel.fullAccess,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: currentUser,
),
),
);
// Tap the EditAccessLevelWidget to open the menu
await tester.tap(find.byType(EditAccessLevelWidget));
await tester.pumpAndSettle();
// Permission change options should be visible
expect(find.text(ShareAccessLevel.readOnly.title), findsWidgets);
expect(find.text(ShareAccessLevel.readAndWrite.title), findsWidgets);
expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget);
});
testWidgets('full access user can turn a guest into member',
(WidgetTester tester) async {
bool turnedIntoMember = false;
final guestUser = SharedUser(
name: 'Guest User',
email: 'guest@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
);
final currentUser = SharedUser(
name: 'Full Access User',
email: 'full@user.com',
accessLevel: ShareAccessLevel.fullAccess,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: true,
user: guestUser,
currentUser: currentUser,
callbacks: AccessLevelListCallbacks(
onSelectAccessLevel: (_) {},
onTurnIntoMember: () {
turnedIntoMember = true;
},
onRemoveAccess: () {},
),
),
),
);
// The TurnIntoMemberWidget should be present
expect(find.byType(TurnIntoMemberWidget), findsOneWidget);
// Tap the button (AFGhostButton inside TurnIntoMemberWidget)
await tester.tap(find.byType(TurnIntoMemberWidget));
await tester.pumpAndSettle();
expect(turnedIntoMember, isTrue);
});
// Additional tests for more coverage
testWidgets('public page: member/owner always gets disabled button',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Member User',
email: 'member@user.com',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
);
final currentUser =
user.copyWith(accessLevel: ShareAccessLevel.fullAccess);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: true,
user: user,
currentUser: currentUser,
),
),
);
expect(find.byType(AFGhostTextButton), findsOneWidget);
expect(find.byType(EditAccessLevelWidget), findsNothing);
});
testWidgets('private page: full access user can manage others',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Other User',
email: 'other@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
);
final currentUser = SharedUser(
name: 'Full Access User',
email: 'full@user.com',
accessLevel: ShareAccessLevel.fullAccess,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: currentUser,
),
),
);
expect(find.byType(EditAccessLevelWidget), findsOneWidget);
});
testWidgets('private page: readonly user sees disabled button for others',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Other User',
email: 'other@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
);
final currentUser = SharedUser(
name: 'Readonly User',
email: 'readonly@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: currentUser,
),
),
);
expect(find.byType(AFGhostTextButton), findsOneWidget);
expect(find.byType(EditAccessLevelWidget), findsNothing);
});
testWidgets('self: full access user cannot change own access',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Full Access User',
email: 'full@user.com',
accessLevel: ShareAccessLevel.fullAccess,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: user,
),
),
);
expect(find.byType(AFGhostTextButton), findsOneWidget);
expect(find.byType(EditAccessLevelWidget), findsNothing);
});
testWidgets('self: readonly user can only remove self',
(WidgetTester tester) async {
final user = SharedUser(
name: 'Readonly User',
email: 'readonly@user.com',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
);
await tester.pumpWidget(
WidgetTestWrapper(
child: SharedUserWidget(
isInPublicPage: false,
user: user,
currentUser: user,
),
),
);
expect(find.byType(EditAccessLevelWidget), findsOneWidget);
// Open the menu and check only remove access is present
await tester.tap(find.byType(EditAccessLevelWidget));
await tester.pumpAndSettle();
expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget);
});
});
}

View File

@ -2367,7 +2367,7 @@
"seeMore": "See more",
"showMore": "Show more",
"somethingWentWrong": "Something went wrong",
"pageNotExist": "This page doesnt exist",
"pageNotExist": "This page doesn't exist",
"tryAgainOrLater": "Please try again later",
"placeholder": {
"actions": "Search actions..."
@ -3099,7 +3099,7 @@
"many": "{count} members",
"other": "{count} members"
},
"tip": "Youve been invited to Join this workspace with the contact information below. If this is incorrect, contact your administrator to resend the invite.",
"tip": "You've been invited to Join this workspace with the contact information below. If this is incorrect, contact your administrator to resend the invite.",
"joinWorkspace": "Join workspace",
"success": "You've successfully joined the workspace",
"successMessage": "You can now access all the pages and workspaces within it.",
@ -3430,7 +3430,21 @@
"removeAccess": "Remove access",
"turnIntoMember": "Turn into Member",
"you": "(You)",
"guest": "Guest"
"guest": "Guest",
"onlyFullAccessCanInvite": "Only user with full access can invite others",
"invitationSent": "Invitation sent",
"emailAlreadyInList": "The email is already in the list",
"upgradeToProToInviteGuests": "Please upgrade to a Pro plan to invite more guests",
"maxGuestsReached": "You have reached the maximum number of guests",
"removedGuestSuccessfully": "Removed guest successfully",
"updatedAccessLevelSuccessfully": "Updated access level successfully",
"turnedIntoMemberSuccessfully": "Turned into member successfully",
"peopleAboveCanAccessWithLink": "People above can access with the link",
"cantMakeChanges": "Can't make changes",
"canMakeAnyChanges": "Can make any changes",
"generalAccess": "General access",
"peopleWithAccess": "People with access",
"peopleAboveCanAccessWithTheLink": "People above can access with the link"
},
"shareSection": {
"shared": "Shared with me",

View File

@ -808,6 +808,9 @@ pub struct SharePageWithUserPayloadPB {
#[pb(index = 3)]
pub access_level: AFAccessLevelPB,
#[pb(index = 4)]
pub auto_confirm: bool,
}
impl TryInto<ShareViewWithGuestRequest> for SharePageWithUserPayloadPB {