mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: members settings (#4788)
* feat: add member settings * feat: fetch workspace members from server * feat: add translations * feat: implement invite feature * feat: support inviting people via email * feat: support updating member role * feat: add feature flag to control the visibilty of members settings
This commit is contained in:
parent
f7233f6949
commit
8732c3c28b
@ -7,6 +7,7 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
@ -176,6 +177,7 @@ class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
|
||||
DocumentHeaderBlockKeys.coverDetails: coverDetails,
|
||||
DocumentHeaderBlockKeys.icon:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.icon],
|
||||
CustomImageBlockKeys.imageType: '1',
|
||||
};
|
||||
if (cover != null) {
|
||||
attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString();
|
||||
|
25
frontend/appflowy_flutter/lib/shared/feature_flags.dart
Normal file
25
frontend/appflowy_flutter/lib/shared/feature_flags.dart
Normal file
@ -0,0 +1,25 @@
|
||||
/// The [FeatureFlag] is used to control the front-end features of the app.
|
||||
///
|
||||
/// For example, if your feature is still under development,
|
||||
/// you can set the value to `false` to hide the feature.
|
||||
enum FeatureFlag {
|
||||
// Feature flags
|
||||
|
||||
// used to control the visibility of the collaborative workspace feature
|
||||
// if it's on, you can see the workspace list and the workspace settings
|
||||
// in the top-left corner of the app
|
||||
collaborativeWorkspace,
|
||||
|
||||
// used to control the visibility of the members settings
|
||||
// if it's on, you can see the members settings in the settings page
|
||||
membersSettings;
|
||||
|
||||
bool get isOn {
|
||||
switch (this) {
|
||||
case FeatureFlag.collaborativeWorkspace:
|
||||
return false;
|
||||
case FeatureFlag.membersSettings:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -16,6 +16,7 @@ enum SettingsPage {
|
||||
notifications,
|
||||
cloud,
|
||||
shortcuts,
|
||||
member,
|
||||
}
|
||||
|
||||
class SettingsDialogBloc
|
||||
|
@ -1,18 +1,20 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart';
|
||||
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.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';
|
||||
|
||||
import 'widgets/setting_cloud.dart';
|
||||
|
||||
const _dialogHorizontalPadding = EdgeInsets.symmetric(horizontal: 12);
|
||||
@ -110,6 +112,8 @@ class SettingsDialog extends StatelessWidget {
|
||||
);
|
||||
case SettingsPage.shortcuts:
|
||||
return const SettingsCustomizeShortcutsWrapper();
|
||||
case SettingsPage.member:
|
||||
return WorkspaceMembersPage(userProfile: user);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
@ -0,0 +1,158 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'workspace_member_bloc.freezed.dart';
|
||||
|
||||
// 1. get the workspace members
|
||||
// 2. display the content based on the user role
|
||||
// Owner:
|
||||
// - invite member button
|
||||
// - delete member button
|
||||
// - member list
|
||||
// Member:
|
||||
// Guest:
|
||||
// - member list
|
||||
class WorkspaceMemberBloc
|
||||
extends Bloc<WorkspaceMemberEvent, WorkspaceMemberState> {
|
||||
WorkspaceMemberBloc({
|
||||
required this.userProfile,
|
||||
}) : super(WorkspaceMemberState.initial()) {
|
||||
on<WorkspaceMemberEvent>((event, emit) async {
|
||||
await event.map(
|
||||
getWorkspaceMembers: (_) async {
|
||||
final members = await _getWorkspaceMembers();
|
||||
final myRole = _getMyRole(members);
|
||||
emit(
|
||||
state.copyWith(
|
||||
members: members,
|
||||
myRole: myRole,
|
||||
),
|
||||
);
|
||||
},
|
||||
addWorkspaceMember: (e) async {
|
||||
await _addWorkspaceMember(e.email);
|
||||
add(const WorkspaceMemberEvent.getWorkspaceMembers());
|
||||
},
|
||||
removeWorkspaceMember: (e) async {
|
||||
await _removeWorkspaceMember(e.email);
|
||||
add(const WorkspaceMemberEvent.getWorkspaceMembers());
|
||||
},
|
||||
updateWorkspaceMember: (e) async {
|
||||
await _updateWorkspaceMember(e.email, e.role);
|
||||
add(const WorkspaceMemberEvent.getWorkspaceMembers());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
Future<List<WorkspaceMemberPB>> _getWorkspaceMembers() async {
|
||||
// will the current workspace be synced across the app?
|
||||
final currentWorkspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return currentWorkspace.fold((s) async {
|
||||
final data = QueryWorkspacePB()..workspaceId = s.id;
|
||||
final result = await UserEventGetWorkspaceMember(data).send();
|
||||
return result.fold((s) => s.items, (e) {
|
||||
Log.error('Failed to read workspace members: $e');
|
||||
return [];
|
||||
});
|
||||
}, (e) {
|
||||
Log.error('Failed to read current workspace: $e');
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
AFRolePB _getMyRole(List<WorkspaceMemberPB> members) {
|
||||
final role = members
|
||||
.firstWhereOrNull(
|
||||
(e) => e.email == userProfile.email,
|
||||
)
|
||||
?.role;
|
||||
if (role == null) {
|
||||
Log.error('Failed to get my role');
|
||||
return AFRolePB.Guest;
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
Future<void> _addWorkspaceMember(String email) async {
|
||||
final currentWorkspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return currentWorkspace.fold((s) async {
|
||||
final data = AddWorkspaceMemberPB()
|
||||
..workspaceId = s.id
|
||||
..email = email;
|
||||
final result = await UserEventAddWorkspaceMember(data).send();
|
||||
result.fold((s) {
|
||||
Log.info('Added workspace member: $data');
|
||||
}, (e) {
|
||||
Log.error('Failed to add workspace member: $e');
|
||||
});
|
||||
}, (e) {
|
||||
Log.error('Failed to read current workspace: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _removeWorkspaceMember(String email) async {
|
||||
final currentWorkspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return currentWorkspace.fold((s) async {
|
||||
final data = RemoveWorkspaceMemberPB()
|
||||
..workspaceId = s.id
|
||||
..email = email;
|
||||
final result = await UserEventRemoveWorkspaceMember(data).send();
|
||||
result.fold((s) {
|
||||
Log.info('Removed workspace member: $data');
|
||||
}, (e) {
|
||||
Log.error('Failed to remove workspace member: $e');
|
||||
});
|
||||
}, (e) {
|
||||
Log.error('Failed to read current workspace: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateWorkspaceMember(String email, AFRolePB role) async {
|
||||
final currentWorkspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return currentWorkspace.fold((s) async {
|
||||
final data = UpdateWorkspaceMemberPB()
|
||||
..workspaceId = s.id
|
||||
..email = email
|
||||
..role = role;
|
||||
final result = await UserEventUpdateWorkspaceMember(data).send();
|
||||
result.fold((s) {
|
||||
Log.info('Updated workspace member: $data');
|
||||
}, (e) {
|
||||
Log.error('Failed to update workspace member: $e');
|
||||
});
|
||||
}, (e) {
|
||||
Log.error('Failed to read current workspace: $e');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class WorkspaceMemberEvent with _$WorkspaceMemberEvent {
|
||||
const factory WorkspaceMemberEvent.getWorkspaceMembers() =
|
||||
GetWorkspaceMembers;
|
||||
const factory WorkspaceMemberEvent.addWorkspaceMember(String email) =
|
||||
AddWorkspaceMember;
|
||||
const factory WorkspaceMemberEvent.removeWorkspaceMember(String email) =
|
||||
RemoveWorkspaceMember;
|
||||
const factory WorkspaceMemberEvent.updateWorkspaceMember(
|
||||
String email,
|
||||
AFRolePB role,
|
||||
) = UpdateWorkspaceMember;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class WorkspaceMemberState with _$WorkspaceMemberState {
|
||||
const factory WorkspaceMemberState({
|
||||
@Default([]) List<WorkspaceMemberPB> members,
|
||||
@Default(AFRolePB.Guest) AFRolePB myRole,
|
||||
}) = _WorkspaceMemberState;
|
||||
|
||||
factory WorkspaceMemberState.initial() => const WorkspaceMemberState();
|
||||
}
|
@ -0,0 +1,478 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class WorkspaceMembersPage extends StatelessWidget {
|
||||
const WorkspaceMembersPage({
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<WorkspaceMemberBloc>(
|
||||
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
|
||||
..add(
|
||||
const WorkspaceMemberEvent.getWorkspaceMembers(),
|
||||
),
|
||||
child: BlocBuilder<WorkspaceMemberBloc, WorkspaceMemberState>(
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// title
|
||||
FlowyText.semibold(
|
||||
LocaleKeys.settings_appearance_members_title.tr(),
|
||||
fontSize: 20,
|
||||
),
|
||||
if (state.myRole.canInvite) const _InviteMember(),
|
||||
if (state.members.isNotEmpty)
|
||||
_MemberList(
|
||||
members: state.members,
|
||||
userProfile: userProfile,
|
||||
myRole: state.myRole,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InviteMember extends StatefulWidget {
|
||||
const _InviteMember();
|
||||
|
||||
@override
|
||||
State<_InviteMember> createState() => _InviteMemberState();
|
||||
}
|
||||
|
||||
class _InviteMemberState extends State<_InviteMember> {
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(12.0),
|
||||
FlowyText.semibold(
|
||||
LocaleKeys.settings_appearance_members_inviteMembers.tr(),
|
||||
fontSize: 16.0,
|
||||
),
|
||||
const VSpace(8.0),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 48.0,
|
||||
),
|
||||
child: FlowyTextField(
|
||||
controller: _emailController,
|
||||
onEditingComplete: _inviteMember,
|
||||
),
|
||||
),
|
||||
),
|
||||
const HSpace(10.0),
|
||||
SizedBox(
|
||||
height: 48.0,
|
||||
child: IntrinsicWidth(
|
||||
child: RoundedTextButton(
|
||||
title: LocaleKeys.settings_appearance_members_sendInvite.tr(),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
onPressed: _inviteMember,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(16.0),
|
||||
PrimaryButton(
|
||||
backgroundColor: const Color(0xFFE0E0E0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 24,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.invite_member_link_m,
|
||||
color: Colors.black,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_appearance_members_copyInviteLink.tr(),
|
||||
color: Colors.black,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showSnackBarMessage(context, 'not implemented');
|
||||
},
|
||||
),
|
||||
const VSpace(16.0),
|
||||
const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _inviteMember() {
|
||||
final email = _emailController.text;
|
||||
if (!isEmail(email)) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context
|
||||
.read<WorkspaceMemberBloc>()
|
||||
.add(WorkspaceMemberEvent.addWorkspaceMember(email));
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.settings_appearance_members_emailSent.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberList extends StatelessWidget {
|
||||
const _MemberList({
|
||||
required this.members,
|
||||
required this.myRole,
|
||||
required this.userProfile,
|
||||
});
|
||||
|
||||
final List<WorkspaceMemberPB> members;
|
||||
final AFRolePB myRole;
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const VSpace(16.0),
|
||||
SeparatedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
separatorBuilder: () => const Divider(),
|
||||
children: [
|
||||
const _MemberListHeader(),
|
||||
...members.map(
|
||||
(member) => _MemberItem(
|
||||
member: member,
|
||||
myRole: myRole,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberListHeader extends StatelessWidget {
|
||||
const _MemberListHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText.semibold(
|
||||
LocaleKeys.settings_appearance_members_label.tr(),
|
||||
fontSize: 16.0,
|
||||
),
|
||||
const VSpace(16.0),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.settings_appearance_members_user.tr(),
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.settings_appearance_members_role.tr(),
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
const HSpace(28.0),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberItem extends StatelessWidget {
|
||||
const _MemberItem({
|
||||
required this.member,
|
||||
required this.myRole,
|
||||
required this.userProfile,
|
||||
});
|
||||
|
||||
final WorkspaceMemberPB member;
|
||||
final AFRolePB myRole;
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = member.role.isOwner ? Theme.of(context).hintColor : null;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText.medium(
|
||||
member.name,
|
||||
color: textColor,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: member.role.isOwner || !myRole.canUpdate
|
||||
? FlowyText.medium(
|
||||
member.role.description,
|
||||
color: textColor,
|
||||
fontSize: 14.0,
|
||||
)
|
||||
: _MemberRoleActionList(
|
||||
member: member,
|
||||
),
|
||||
),
|
||||
myRole.canDelete &&
|
||||
member.email != userProfile.email // can't delete self
|
||||
? _MemberMoreActionList(member: member)
|
||||
: const HSpace(28.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _MemberMoreAction {
|
||||
delete,
|
||||
}
|
||||
|
||||
class _MemberMoreActionList extends StatelessWidget {
|
||||
const _MemberMoreActionList({
|
||||
required this.member,
|
||||
});
|
||||
|
||||
final WorkspaceMemberPB member;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<_MemberMoreActionWrapper>(
|
||||
asBarrier: true,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
actions: _MemberMoreAction.values
|
||||
.map((e) => _MemberMoreActionWrapper(e, member))
|
||||
.toList(),
|
||||
buildChild: (controller) {
|
||||
return FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: const FlowySvg(
|
||||
FlowySvgs.three_dots_vertical_s,
|
||||
),
|
||||
onTap: () {
|
||||
controller.show();
|
||||
},
|
||||
);
|
||||
},
|
||||
onSelected: (action, controller) async {
|
||||
switch (action.inner) {
|
||||
case _MemberMoreAction.delete:
|
||||
context.read<WorkspaceMemberBloc>().add(
|
||||
WorkspaceMemberEvent.removeWorkspaceMember(
|
||||
action.member.email,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberMoreActionWrapper extends ActionCell {
|
||||
_MemberMoreActionWrapper(this.inner, this.member);
|
||||
|
||||
final _MemberMoreAction inner;
|
||||
final WorkspaceMemberPB member;
|
||||
|
||||
@override
|
||||
String get name {
|
||||
switch (inner) {
|
||||
case _MemberMoreAction.delete:
|
||||
return LocaleKeys.settings_appearance_members_removeFromWorkspace.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberRoleActionList extends StatelessWidget {
|
||||
const _MemberRoleActionList({
|
||||
required this.member,
|
||||
});
|
||||
|
||||
final WorkspaceMemberPB member;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<_MemberRoleActionWrapper>(
|
||||
asBarrier: true,
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
actions: [AFRolePB.Member, AFRolePB.Guest]
|
||||
.map((e) => _MemberRoleActionWrapper(e, member))
|
||||
.toList(),
|
||||
offset: const Offset(0, 10),
|
||||
buildChild: (controller) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => controller.show(),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
member.role.description,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
const FlowySvg(
|
||||
FlowySvgs.drop_menu_show_s,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSelected: (action, controller) async {
|
||||
switch (action.inner) {
|
||||
case AFRolePB.Member:
|
||||
case AFRolePB.Guest:
|
||||
context.read<WorkspaceMemberBloc>().add(
|
||||
WorkspaceMemberEvent.updateWorkspaceMember(
|
||||
action.member.email,
|
||||
action.inner,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case AFRolePB.Owner:
|
||||
break;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberRoleActionWrapper extends ActionCell {
|
||||
_MemberRoleActionWrapper(this.inner, this.member);
|
||||
|
||||
final AFRolePB inner;
|
||||
final WorkspaceMemberPB member;
|
||||
|
||||
@override
|
||||
Widget? rightIcon(Color iconColor) {
|
||||
return SizedBox(
|
||||
width: 58.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyTooltip(
|
||||
message: tooltip,
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.information_s,
|
||||
// color: iconColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (member.role == inner)
|
||||
const FlowySvg(
|
||||
FlowySvgs.checkmark_tiny_s,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get name {
|
||||
switch (inner) {
|
||||
case AFRolePB.Guest:
|
||||
return LocaleKeys.settings_appearance_members_guest.tr();
|
||||
case AFRolePB.Member:
|
||||
return LocaleKeys.settings_appearance_members_member.tr();
|
||||
case AFRolePB.Owner:
|
||||
return LocaleKeys.settings_appearance_members_owner.tr();
|
||||
}
|
||||
throw UnimplementedError('Unknown role: $inner');
|
||||
}
|
||||
|
||||
String get tooltip {
|
||||
switch (inner) {
|
||||
case AFRolePB.Guest:
|
||||
return LocaleKeys.settings_appearance_members_guestHintText.tr();
|
||||
case AFRolePB.Member:
|
||||
return LocaleKeys.settings_appearance_members_memberHintText.tr();
|
||||
case AFRolePB.Owner:
|
||||
return '';
|
||||
}
|
||||
throw UnimplementedError('Unknown role: $inner');
|
||||
}
|
||||
}
|
||||
|
||||
extension on AFRolePB {
|
||||
bool get isOwner => this == AFRolePB.Owner;
|
||||
|
||||
bool get canInvite => isOwner;
|
||||
|
||||
bool get canDelete => isOwner;
|
||||
|
||||
bool get canUpdate => isOwner;
|
||||
|
||||
String get description {
|
||||
switch (this) {
|
||||
case AFRolePB.Owner:
|
||||
return LocaleKeys.settings_appearance_members_owner.tr();
|
||||
case AFRolePB.Member:
|
||||
return LocaleKeys.settings_appearance_members_member.tr();
|
||||
case AFRolePB.Guest:
|
||||
return LocaleKeys.settings_appearance_members_guest.tr();
|
||||
}
|
||||
throw UnimplementedError('Unknown role: $this');
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsMenu extends StatelessWidget {
|
||||
@ -16,7 +18,9 @@ class SettingsMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return SingleChildScrollView(
|
||||
child: SeparatedColumn(
|
||||
separatorBuilder: () => const SizedBox(height: 10),
|
||||
children: [
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.appearance,
|
||||
@ -25,7 +29,6 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.brightness_4,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.language,
|
||||
selectedPage: currentPage,
|
||||
@ -33,7 +36,6 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.translate,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.files,
|
||||
selectedPage: currentPage,
|
||||
@ -41,7 +43,6 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.file_present_outlined,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.user,
|
||||
selectedPage: currentPage,
|
||||
@ -49,7 +50,6 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.account_box_outlined,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.notifications,
|
||||
selectedPage: currentPage,
|
||||
@ -57,7 +57,6 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.notifications_outlined,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.cloud,
|
||||
selectedPage: currentPage,
|
||||
@ -65,7 +64,6 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.sync,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.shortcuts,
|
||||
selectedPage: currentPage,
|
||||
@ -73,7 +71,16 @@ class SettingsMenu extends StatelessWidget {
|
||||
icon: Icons.cut,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
if (FeatureFlag.membersSettings.isOn)
|
||||
SettingsMenuElement(
|
||||
page: SettingsPage.member,
|
||||
selectedPage: currentPage,
|
||||
label: LocaleKeys.settings_appearance_members_label.tr(),
|
||||
icon: Icons.people,
|
||||
changeSelectedPage: changeSelectedPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,14 @@ abstract class FlowyResult<S, F> {
|
||||
bool isFailure();
|
||||
|
||||
S? toNullable();
|
||||
|
||||
void onSuccess(
|
||||
void Function(S s) onSuccess,
|
||||
);
|
||||
|
||||
void onFailure(
|
||||
void Function(F f) onFailure,
|
||||
);
|
||||
}
|
||||
|
||||
class FlowySuccess<S, F> implements FlowyResult<S, F> {
|
||||
@ -64,6 +72,14 @@ class FlowySuccess<S, F> implements FlowyResult<S, F> {
|
||||
S? toNullable() {
|
||||
return _value;
|
||||
}
|
||||
|
||||
@override
|
||||
void onSuccess(void Function(S success) onSuccess) {
|
||||
onSuccess(_value);
|
||||
}
|
||||
|
||||
@override
|
||||
void onFailure(void Function(F failure) onFailure) {}
|
||||
}
|
||||
|
||||
class FlowyFailure<S, F> implements FlowyResult<S, F> {
|
||||
@ -114,4 +130,12 @@ class FlowyFailure<S, F> implements FlowyResult<S, F> {
|
||||
S? toNullable() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onSuccess(void Function(S success) onSuccess) {}
|
||||
|
||||
@override
|
||||
void onFailure(void Function(F failure) onFailure) {
|
||||
onFailure(_error);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'base_styled_button.dart';
|
||||
import 'secondary_button.dart';
|
||||
|
||||
@ -25,15 +26,18 @@ class PrimaryTextButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
class PrimaryButton extends StatelessWidget {
|
||||
const PrimaryButton({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.mode = TextButtonMode.big,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final TextButtonMode mode;
|
||||
|
||||
const PrimaryButton(
|
||||
{super.key,
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.mode = TextButtonMode.big});
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -41,7 +45,7 @@ class PrimaryButton extends StatelessWidget {
|
||||
minWidth: mode.size.width,
|
||||
minHeight: mode.size.height,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
bgColor: Theme.of(context).colorScheme.primary,
|
||||
bgColor: backgroundColor ?? Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: mode.borderRadius,
|
||||
onPressed: onPressed,
|
||||
|
@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget {
|
||||
final Color? hoverColor;
|
||||
final Color? textColor;
|
||||
final double? fontSize;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const RoundedTextButton({
|
||||
super.key,
|
||||
@ -26,6 +27,7 @@ class RoundedTextButton extends StatelessWidget {
|
||||
this.hoverColor,
|
||||
this.textColor,
|
||||
this.fontSize,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
});
|
||||
|
||||
@override
|
||||
@ -48,6 +50,7 @@ class RoundedTextButton extends StatelessWidget {
|
||||
fillColor: fillColor ?? Theme.of(context).colorScheme.primary,
|
||||
hoverColor:
|
||||
hoverColor ?? Theme.of(context).colorScheme.primaryContainer,
|
||||
padding: padding,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
10
frontend/resources/flowy_icons/24x/invite_member_link.svg
Normal file
10
frontend/resources/flowy_icons/24x/invite_member_link.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="link">
|
||||
<mask id="mask0_1032_7497" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||
<rect id="Bounding box" width="24" height="24" fill="#D9D9D9"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1032_7497)">
|
||||
<path id="link_2" d="M11 17H7C5.61667 17 4.4375 16.5125 3.4625 15.5375C2.4875 14.5625 2 13.3833 2 12C2 10.6167 2.4875 9.4375 3.4625 8.4625C4.4375 7.4875 5.61667 7 7 7H11V9H7C6.16667 9 5.45833 9.29167 4.875 9.875C4.29167 10.4583 4 11.1667 4 12C4 12.8333 4.29167 13.5417 4.875 14.125C5.45833 14.7083 6.16667 15 7 15H11V17ZM8 13V11H16V13H8ZM13 17V15H17C17.8333 15 18.5417 14.7083 19.125 14.125C19.7083 13.5417 20 12.8333 20 12C20 11.1667 19.7083 10.4583 19.125 9.875C18.5417 9.29167 17.8333 9 17 9H13V7H17C18.3833 7 19.5625 7.4875 20.5375 8.4625C21.5125 9.4375 22 10.6167 22 12C22 13.3833 21.5125 14.5625 20.5375 15.5375C19.5625 16.5125 18.3833 17 17 17H13Z" fill="#1C1B1F"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1019 B |
@ -397,7 +397,24 @@
|
||||
"twentyFourHour": "Twenty four hour"
|
||||
},
|
||||
"showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page",
|
||||
"enableRTLToolbarItems": "Enable RTL toolbar items"
|
||||
"enableRTLToolbarItems": "Enable RTL toolbar items",
|
||||
"members": {
|
||||
"title": "Members Settings",
|
||||
"inviteMembers": "Invite Members",
|
||||
"sendInvite": "Send Invite",
|
||||
"copyInviteLink": "Copy Invite Link",
|
||||
"label": "Members",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"removeFromWorkspace": "Remove from Workspace",
|
||||
"owner": "Owner",
|
||||
"guest": "Guest",
|
||||
"member": "Member",
|
||||
"memberHintText": "A member can read, comment, and edit pages. Invite members and guests.",
|
||||
"guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.",
|
||||
"emailInvalidError": "Invalid email, please check and try again",
|
||||
"emailSent": "Email sent, please check the inbox"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"copy": "Copy",
|
||||
|
@ -180,16 +180,16 @@ pub enum UserEvent {
|
||||
#[event(output = "NotificationSettingsPB")]
|
||||
GetNotificationSettings = 36,
|
||||
|
||||
#[event(output = "AddWorkspaceMemberPB")]
|
||||
#[event(input = "AddWorkspaceMemberPB")]
|
||||
AddWorkspaceMember = 37,
|
||||
|
||||
#[event(output = "RemoveWorkspaceMemberPB")]
|
||||
#[event(input = "RemoveWorkspaceMemberPB")]
|
||||
RemoveWorkspaceMember = 38,
|
||||
|
||||
#[event(output = "UpdateWorkspaceMemberPB")]
|
||||
#[event(input = "UpdateWorkspaceMemberPB")]
|
||||
UpdateWorkspaceMember = 39,
|
||||
|
||||
#[event(output = "QueryWorkspacePB")]
|
||||
#[event(input = "QueryWorkspacePB", output = "RepeatedWorkspaceMemberPB")]
|
||||
GetWorkspaceMember = 40,
|
||||
|
||||
#[event(input = "ImportAppFlowyDataPB")]
|
||||
|
Loading…
Reference in New Issue
Block a user