diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart index 78e458f7ab..9ca7f9ec8c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart @@ -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 { DocumentHeaderBlockKeys.coverDetails: coverDetails, DocumentHeaderBlockKeys.icon: widget.node.attributes[DocumentHeaderBlockKeys.icon], + CustomImageBlockKeys.imageType: '1', }; if (cover != null) { attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString(); diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart new file mode 100644 index 0000000000..8e94c46d61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -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; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index f1f369725e..8f40d706f6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -16,6 +16,7 @@ enum SettingsPage { notifications, cloud, shortcuts, + member, } class SettingsDialogBloc diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index c57378ce71..c24a6a9fab 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -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(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart new file mode 100644 index 0000000000..8bc317390e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart @@ -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 { + WorkspaceMemberBloc({ + required this.userProfile, + }) : super(WorkspaceMemberState.initial()) { + on((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> _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 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 _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 _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 _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 members, + @Default(AFRolePB.Guest) AFRolePB myRole, + }) = _WorkspaceMemberState; + + factory WorkspaceMemberState.initial() => const WorkspaceMemberState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart new file mode 100644 index 0000000000..f43e376340 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart @@ -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( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add( + const WorkspaceMemberEvent.getWorkspaceMembers(), + ), + child: BlocBuilder( + 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() + .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 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().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().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'); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index a5f3e889cc..278194bb4d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -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,64 +18,69 @@ class SettingsMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - SettingsMenuElement( - page: SettingsPage.appearance, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_appearance.tr(), - icon: Icons.brightness_4, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.language, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_language.tr(), - icon: Icons.translate, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.files, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_files.tr(), - icon: Icons.file_present_outlined, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.user, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_user.tr(), - icon: Icons.account_box_outlined, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.notifications, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_notifications.tr(), - icon: Icons.notifications_outlined, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.cloud, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_cloudSettings.tr(), - icon: Icons.sync, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.shortcuts, - selectedPage: currentPage, - label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), - icon: Icons.cut, - changeSelectedPage: changeSelectedPage, - ), - ], + return SingleChildScrollView( + child: SeparatedColumn( + separatorBuilder: () => const SizedBox(height: 10), + children: [ + SettingsMenuElement( + page: SettingsPage.appearance, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_appearance.tr(), + icon: Icons.brightness_4, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.language, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_language.tr(), + icon: Icons.translate, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.files, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_files.tr(), + icon: Icons.file_present_outlined, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.user, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_user.tr(), + icon: Icons.account_box_outlined, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.notifications, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_notifications.tr(), + icon: Icons.notifications_outlined, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.cloud, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_cloudSettings.tr(), + icon: Icons.sync, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.shortcuts, + selectedPage: currentPage, + label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), + 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, + ), + ], + ), ); } } diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart index ac056f3b97..88d3051332 100644 --- a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart @@ -14,6 +14,14 @@ abstract class FlowyResult { bool isFailure(); S? toNullable(); + + void onSuccess( + void Function(S s) onSuccess, + ); + + void onFailure( + void Function(F f) onFailure, + ); } class FlowySuccess implements FlowyResult { @@ -64,6 +72,14 @@ class FlowySuccess implements FlowyResult { S? toNullable() { return _value; } + + @override + void onSuccess(void Function(S success) onSuccess) { + onSuccess(_value); + } + + @override + void onFailure(void Function(F failure) onFailure) {} } class FlowyFailure implements FlowyResult { @@ -114,4 +130,12 @@ class FlowyFailure implements FlowyResult { S? toNullable() { return null; } + + @override + void onSuccess(void Function(S success) onSuccess) {} + + @override + void onFailure(void Function(F failure) onFailure) { + onFailure(_error); + } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart index a2fe2f091e..6207419009 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart @@ -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, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart index d4e9ed48c1..11b71b7d28 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart @@ -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, ), ), ); diff --git a/frontend/resources/flowy_icons/24x/invite_member_link.svg b/frontend/resources/flowy_icons/24x/invite_member_link.svg new file mode 100644 index 0000000000..c9cf445a8f --- /dev/null +++ b/frontend/resources/flowy_icons/24x/invite_member_link.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 5ceecd7cc9..d105849aa6 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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", diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 6dbeb877b2..2bcd60bbf7 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -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")]