Merge branch 'main' into integrate_workspace_template

This commit is contained in:
Lucas.Xu 2025-04-22 10:59:39 +08:00
commit c10c844fa9
22 changed files with 1141 additions and 608 deletions

View file

@ -1,13 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -38,7 +37,7 @@ void main() {
LocaleKeys.settings_workspacePage_appearance_options_light.tr(),
),
);
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(milliseconds: 250));
themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
expect(themeMode, ThemeMode.light);
@ -48,7 +47,7 @@ void main() {
LocaleKeys.settings_workspacePage_appearance_options_dark.tr(),
),
);
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(milliseconds: 250));
themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
expect(themeMode, ThemeMode.dark);
@ -66,10 +65,11 @@ void main() {
],
tester: tester,
);
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(milliseconds: 500));
themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
expect(themeMode, ThemeMode.light);
// disable it temporarily. It works on macOS but not on Linux.
// themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
// expect(themeMode, ThemeMode.light);
});
testWidgets('show or hide home menu', (tester) async {

View file

@ -44,10 +44,18 @@ Future<bool> afLaunchUri(
uri = Uri.parse('https://$url');
}
// try to launch the uri directly
bool result = await launcher.canLaunchUrl(uri);
/// opening an incorrect link will cause a system error dialog to pop up on macOS
/// only use [canLaunchUrl] on macOS
/// and there is an known issue with url_launcher on Linux where it fails to launch
/// see https://github.com/flutter/flutter/issues/88463
bool result = true;
if (UniversalPlatform.isMacOS) {
result = await launcher.canLaunchUrl(uri);
}
if (result) {
try {
// try to launch the uri directly
result = await launcher.launchUrl(
uri,
mode: mode,

View file

@ -197,7 +197,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.add) {
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
@ -223,7 +223,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
);
},
);
} else if (actionType == WorkspaceMemberActionType.invite) {
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
@ -250,7 +250,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
);
},
);
} else if (actionType == WorkspaceMemberActionType.remove) {
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
@ -284,7 +284,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMember(email));
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}

View file

@ -178,7 +178,7 @@ class _MemberItem extends StatelessWidget {
showBottomBorder: false,
onTap: () {
workspaceMemberBloc.add(
WorkspaceMemberEvent.removeWorkspaceMember(
WorkspaceMemberEvent.removeWorkspaceMemberByEmail(
member.email,
),
);

View file

@ -0,0 +1,16 @@
import 'dart:convert';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
extension UserProfilePBExtension on UserProfilePB {
String? get authToken {
try {
final map = jsonDecode(token) as Map<String, dynamic>;
return map['access_token'] as String?;
} catch (e) {
Log.error('Failed to decode auth token: $e');
return null;
}
}
}

View file

@ -138,6 +138,8 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
final _commandPaletteNotifier = ValueNotifier<bool>(false);
final themeBuilder = AppFlowyDefaultTheme();
@override
void initState() {
super.initState();
@ -235,10 +237,9 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
locale: state.locale,
routerConfig: routerConfig,
builder: (context, child) {
final themeBuilder = AppFlowyDefaultTheme();
final brightness = Theme.of(context).brightness;
return AnimatedAppFlowyTheme(
return AppFlowyTheme(
data: brightness == Brightness.light
? themeBuilder.light()
: themeBuilder.dark(),

View file

@ -35,6 +35,8 @@ class _ContinueWithMagicLinkOrPasscodePageState
final inputPasscodeKey = GlobalKey<AFTextFieldState>();
bool isSubmitting = false;
@override
void dispose() {
passcodeController.dispose();
@ -54,6 +56,10 @@ class _ContinueWithMagicLinkOrPasscodePageState
);
});
}
if (state.isSubmitting != isSubmitting) {
setState(() => isSubmitting = state.isSubmitting);
}
},
child: Scaffold(
body: Center(
@ -81,6 +87,15 @@ class _ContinueWithMagicLinkOrPasscodePageState
List<Widget> _buildEnterCodeManually() {
// todo: ask designer to provide the spacing
final spacing = VSpace(20);
final textStyle = AFButtonSize.l.buildTextStyle(context);
final textHeight = textStyle.height;
final textFontSize = textStyle.fontSize;
// the indicator height is the height of the text style.
double indicatorHeight = 20;
if (textHeight != null && textFontSize != null) {
indicatorHeight = textHeight * textFontSize;
}
if (!isEnteringPasscode) {
return [
@ -116,26 +131,55 @@ class _ContinueWithMagicLinkOrPasscodePageState
VSpace(12),
// continue to login
AFFilledTextButton.primary(
text: LocaleKeys.signIn_continueToSignIn.tr(),
onTap: () {
final passcode = passcodeController.text;
if (passcode.isEmpty) {
inputPasscodeKey.currentState?.syncError(
errorText: LocaleKeys.signIn_invalidVerificationCode.tr(),
);
} else {
widget.onEnterPasscode(passcode);
}
},
size: AFButtonSize.l,
alignment: Alignment.center,
),
!isSubmitting
? _buildContinueButton(textStyle: textStyle)
: _buildIndicator(indicatorHeight: indicatorHeight),
spacing,
];
}
Widget _buildContinueButton({
required TextStyle textStyle,
}) {
return AFFilledTextButton.primary(
text: LocaleKeys.signIn_continueToSignIn.tr(),
onTap: () {
final passcode = passcodeController.text;
if (passcode.isEmpty) {
inputPasscodeKey.currentState?.syncError(
errorText: LocaleKeys.signIn_invalidVerificationCode.tr(),
);
} else {
widget.onEnterPasscode(passcode);
}
},
textStyle: textStyle.copyWith(
color: AppFlowyTheme.of(context).textColorScheme.onFill,
),
size: AFButtonSize.l,
alignment: Alignment.center,
);
}
Widget _buildIndicator({
required double indicatorHeight,
}) {
return AFFilledButton.disabled(
size: AFButtonSize.l,
builder: (context, isHovering, disabled) {
return Align(
child: SizedBox.square(
dimension: indicatorHeight,
child: CircularProgressIndicator(
strokeWidth: 3.0,
),
),
);
},
);
}
List<Widget> _buildBackToLogin() {
return [
AFGhostTextButton(

View file

@ -13,6 +13,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
@ -173,42 +174,53 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
required this.onConfirm,
required this.confirmButtonName,
this.confirmButtonColor,
this.confirmButtonBuilder,
});
final VoidCallback onCancel;
final VoidCallback onConfirm;
final String confirmButtonName;
final Color? confirmButtonColor;
final WidgetBuilder? confirmButtonBuilder;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedRoundedButton(
AFOutlinedTextButton.normal(
text: LocaleKeys.button_cancel.tr(),
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
onTap: onCancel,
),
const HSpace(12.0),
DecoratedBox(
decoration: ShapeDecoration(
color: confirmButtonColor ?? Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
if (confirmButtonBuilder != null) ...[
confirmButtonBuilder!(context),
] else ...[
DecoratedBox(
decoration: ShapeDecoration(
color:
confirmButtonColor ?? Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0),
radius: BorderRadius.circular(8),
text: FlowyText.regular(
confirmButtonName,
lineHeight: 1.0,
color: Theme.of(context).colorScheme.onPrimary,
),
onTap: onConfirm,
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0),
radius: BorderRadius.circular(8),
text: FlowyText.regular(
confirmButtonName,
lineHeight: 1.0,
color: Theme.of(context).colorScheme.onPrimary,
),
onTap: onConfirm,
),
),
],
],
);
}
@ -249,17 +261,11 @@ enum ConfirmPopupStyle {
class ConfirmPopupColor {
static Color titleColor(BuildContext context) {
if (Theme.of(context).isLightMode) {
return const Color(0xFF171717).withValues(alpha: 0.8);
}
return const Color(0xFFffffff).withValues(alpha: 0.8);
return AppFlowyTheme.of(context).textColorScheme.primary;
}
static Color descriptionColor(BuildContext context) {
if (Theme.of(context).isLightMode) {
return const Color(0xFF171717).withValues(alpha: 0.7);
}
return const Color(0xFFffffff).withValues(alpha: 0.7);
return AppFlowyTheme.of(context).textColorScheme.primary;
}
}
@ -273,6 +279,7 @@ class ConfirmPopup extends StatefulWidget {
this.onCancel,
this.confirmLabel,
this.confirmButtonColor,
this.confirmButtonBuilder,
this.child,
this.closeOnAction = true,
this.showCloseButton = true,
@ -315,6 +322,10 @@ class ConfirmPopup extends StatefulWidget {
///
final bool enableKeyboardListener;
/// Allows to build a custom confirm button.
///
final WidgetBuilder? confirmButtonBuilder;
@override
State<ConfirmPopup> createState() => _ConfirmPopupState();
}
@ -368,28 +379,28 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
}
Widget _buildTitle() {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Expanded(
child: FlowyText(
child: Text(
widget.title,
fontSize: 16.0,
figmaLineHeight: 22.0,
fontWeight: FontWeight.w500,
style: theme.textStyle.heading4.prominent(
color: ConfirmPopupColor.titleColor(context),
),
overflow: TextOverflow.ellipsis,
color: ConfirmPopupColor.titleColor(context),
),
),
const HSpace(6.0),
if (widget.showCloseButton) ...[
FlowyButton(
margin: const EdgeInsets.all(3),
useIntrinsicWidth: true,
text: const FlowySvg(
FlowySvgs.upgrade_close_s,
size: Size.square(18.0),
),
AFGhostButton.normal(
size: AFButtonSize.s,
padding: EdgeInsets.all(theme.spacing.xs),
onTap: () => Navigator.of(context).pop(),
builder: (context, isHovering, disabled) => FlowySvg(
FlowySvgs.password_close_m,
size: const Size.square(20),
),
),
],
],
@ -401,18 +412,24 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
return const SizedBox.shrink();
}
return FlowyText.regular(
final theme = AppFlowyTheme.of(context);
return Text(
widget.description,
fontSize: 16.0,
color: ConfirmPopupColor.descriptionColor(context),
style: theme.textStyle.body.standard(
color: ConfirmPopupColor.descriptionColor(context),
),
maxLines: 5,
figmaLineHeight: 22.0,
);
}
Widget _buildStyledButton(BuildContext context) {
switch (widget.style) {
case ConfirmPopupStyle.onlyOk:
if (widget.confirmButtonBuilder != null) {
return widget.confirmButtonBuilder!(context);
}
return SpaceOkButton(
onConfirm: () {
widget.onConfirm();
@ -440,6 +457,7 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
widget.confirmLabel ?? LocaleKeys.space_delete.tr(),
confirmButtonColor:
widget.confirmButtonColor ?? Theme.of(context).colorScheme.error,
confirmButtonBuilder: widget.confirmButtonBuilder,
);
}
}

View file

@ -1,20 +1,21 @@
import 'package:flutter/material.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class SettingsBody extends StatelessWidget {
const SettingsBody({
super.key,
required this.title,
this.description,
this.descriptionBuilder,
this.autoSeparate = true,
required this.children,
});
final String title;
final String? description;
final WidgetBuilder? descriptionBuilder;
final bool autoSeparate;
final List<Widget> children;
@ -27,7 +28,12 @@ class SettingsBody extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsHeader(title: title, description: description),
SettingsHeader(
title: title,
description: description,
descriptionBuilder: descriptionBuilder,
),
SettingsCategorySpacer(),
Flexible(
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,

View file

@ -11,8 +11,8 @@ class SettingsCategorySpacer extends StatelessWidget {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Divider(
height: 32,
color: theme.borderColorScheme.greyPrimary,
height: theme.spacing.xl * 2.0,
color: theme.borderColorScheme.primary,
);
}
}

View file

@ -1,15 +1,20 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
/// Renders a simple header for the settings view
///
class SettingsHeader extends StatelessWidget {
const SettingsHeader({super.key, required this.title, this.description});
const SettingsHeader({
super.key,
required this.title,
this.description,
this.descriptionBuilder,
});
final String title;
final String? description;
final WidgetBuilder? descriptionBuilder;
@override
Widget build(BuildContext context) {
@ -23,16 +28,19 @@ class SettingsHeader extends StatelessWidget {
color: theme.textColorScheme.primary,
),
),
if (description?.isNotEmpty == true) ...[
const VSpace(8),
FlowyText(
if (descriptionBuilder != null) ...[
VSpace(theme.spacing.xs),
descriptionBuilder!(context),
] else if (description?.isNotEmpty == true) ...[
VSpace(theme.spacing.xs),
Text(
description!,
maxLines: 4,
fontSize: 12,
color: AFThemeExtension.of(context).secondaryTextColor,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
),
],
const VSpace(16),
],
);
}

View file

@ -0,0 +1,154 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class InviteMemberByLink extends StatelessWidget {
const InviteMemberByLink({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Title(),
_Description(),
],
),
Spacer(),
_CopyLinkButton(),
],
);
}
}
class _Title extends StatelessWidget {
const _Title();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Text(
LocaleKeys.settings_appearance_members_inviteLinkToAddMember.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
);
}
}
class _Description extends StatelessWidget {
const _Description();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Text.rich(
TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_appearance_members_clickToCopyLink.tr(),
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.primary,
),
),
TextSpan(
text: ' ${LocaleKeys.settings_appearance_members_or.tr()} ',
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.primary,
),
),
TextSpan(
text: LocaleKeys.settings_appearance_members_generateANewLink.tr(),
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.action,
),
mouseCursor: SystemMouseCursors.click,
recognizer: TapGestureRecognizer()
..onTap = () => _onGenerateInviteLink(context),
),
],
),
);
}
Future<void> _onGenerateInviteLink(BuildContext context) async {
final inviteLink = context.read<WorkspaceMemberBloc>().state.inviteLink;
if (inviteLink != null) {
// show a dialog to confirm if the user wants to copy the link to the clipboard
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: 'Reset the invite link?',
description:
'Resetting will deactivate the current link for all space members and generate a new one. The old link will no longer be available.',
confirmLabel: 'Reset',
onConfirm: () {
context.read<WorkspaceMemberBloc>().add(
const WorkspaceMemberEvent.generateInviteLink(),
);
},
confirmButtonBuilder: (_) => AFFilledTextButton.destructive(
text: 'Reset',
onTap: () {
context.read<WorkspaceMemberBloc>().add(
const WorkspaceMemberEvent.generateInviteLink(),
);
Navigator.of(context).pop();
},
),
);
} else {
context.read<WorkspaceMemberBloc>().add(
const WorkspaceMemberEvent.generateInviteLink(),
);
}
}
}
class _CopyLinkButton extends StatelessWidget {
const _CopyLinkButton();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFOutlinedTextButton.normal(
text: LocaleKeys.button_copyLink.tr(),
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.l,
vertical: theme.spacing.s,
),
onTap: () {
final link = context.read<WorkspaceMemberBloc>().state.inviteLink;
if (link != null) {
getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: link,
),
);
showToastNotification(
message: LocaleKeys.document_inlineLink_copyLink.tr(),
);
} else {
showToastNotification(
message: LocaleKeys.shareAction_copyLinkFailed.tr(),
);
}
},
);
}
}

View file

@ -0,0 +1,79 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.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 'package:string_validator/string_validator.dart';
class InviteMemberByEmail extends StatefulWidget {
const InviteMemberByEmail({super.key});
@override
State<InviteMemberByEmail> createState() => _InviteMemberByEmailState();
}
class _InviteMemberByEmailState extends State<InviteMemberByEmail> {
final _emailController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
LocaleKeys.settings_appearance_members_inviteMemberByEmail.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
),
VSpace(theme.spacing.m),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: AFTextField(
controller: _emailController,
hintText:
LocaleKeys.settings_appearance_members_inviteHint.tr(),
onSubmitted: (value) => _inviteMember(),
),
),
HSpace(theme.spacing.l),
AFFilledTextButton.primary(
text: LocaleKeys.settings_appearance_members_sendInvite.tr(),
onTap: _inviteMember,
),
],
),
],
);
}
void _inviteMember() {
final email = _emailController.text;
if (!isEmail(email)) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
_emailController.clear();
}
}

View file

@ -0,0 +1,181 @@
import 'dart:convert';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:http/http.dart' as http;
enum InviteCodeEndpoint {
getInviteCode,
deleteInviteCode,
generateInviteCode;
String get path {
switch (this) {
case InviteCodeEndpoint.getInviteCode:
case InviteCodeEndpoint.deleteInviteCode:
case InviteCodeEndpoint.generateInviteCode:
return '/api/workspace/{workspaceId}/invite-code';
}
}
String get method {
switch (this) {
case InviteCodeEndpoint.getInviteCode:
return 'GET';
case InviteCodeEndpoint.deleteInviteCode:
return 'DELETE';
case InviteCodeEndpoint.generateInviteCode:
return 'POST';
}
}
Uri uri(String baseUrl, String workspaceId) =>
Uri.parse(path.replaceAll('{workspaceId}', workspaceId)).replace(
scheme: Uri.parse(baseUrl).scheme,
host: Uri.parse(baseUrl).host,
port: Uri.parse(baseUrl).port,
);
}
class MemberHttpService {
MemberHttpService({
required this.baseUrl,
required this.authToken,
});
final String baseUrl;
final String authToken;
final http.Client client = http.Client();
Map<String, String> get headers => {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
};
/// Gets the invite code for a workspace
Future<FlowyResult<String, FlowyError>> getInviteCode({
required String workspaceId,
}) async {
final result = await _makeRequest(
endpoint: InviteCodeEndpoint.getInviteCode,
workspaceId: workspaceId,
errorMessage: 'Failed to get invite code',
);
return result.fold(
(data) => FlowyResult.success(data['code'] as String),
(error) => FlowyResult.failure(error),
);
}
/// Deletes the invite code for a workspace
Future<FlowyResult<bool, FlowyError>> deleteInviteCode({
required String workspaceId,
}) async {
final result = await _makeRequest(
endpoint: InviteCodeEndpoint.deleteInviteCode,
workspaceId: workspaceId,
errorMessage: 'Failed to delete invite code',
);
return result.fold(
(data) => FlowyResult.success(true),
(error) => FlowyResult.failure(error),
);
}
/// Generates a new invite code for a workspace
///
/// [workspaceId] - The ID of the workspace
Future<FlowyResult<String, FlowyError>> generateInviteCode({
required String workspaceId,
int? validityPeriodHours,
}) async {
final result = await _makeRequest(
endpoint: InviteCodeEndpoint.generateInviteCode,
workspaceId: workspaceId,
errorMessage: 'Failed to generate invite code',
body: {
'validity_period_hours': validityPeriodHours,
},
);
try {
return result.fold(
(data) => FlowyResult.success(data['data']['code'].toString()),
(error) => FlowyResult.failure(error),
);
} catch (e) {
return FlowyResult.failure(
FlowyError(msg: 'Failed to generate invite code: $e'),
);
}
}
/// Makes a request to the specified endpoint
Future<FlowyResult<dynamic, FlowyError>> _makeRequest({
required InviteCodeEndpoint endpoint,
required String workspaceId,
Map<String, dynamic>? body,
String errorMessage = 'Request failed',
}) async {
try {
final uri = endpoint.uri(baseUrl, workspaceId);
http.Response response;
switch (endpoint.method) {
case 'GET':
response = await client.get(
uri,
headers: headers,
);
break;
case 'DELETE':
response = await client.delete(
uri,
headers: headers,
);
break;
case 'POST':
response = await client.post(
uri,
headers: headers,
body: body != null ? jsonEncode(body) : null,
);
break;
default:
return FlowyResult.failure(
FlowyError(msg: 'Invalid request method: ${endpoint.method}'),
);
}
if (response.statusCode == 200) {
if (response.body.isNotEmpty) {
return FlowyResult.success(jsonDecode(response.body));
}
return FlowyResult.success(true);
} else {
final errorBody =
response.body.isNotEmpty ? jsonDecode(response.body) : {};
Log.info(
'${endpoint.name} request failed: ${response.statusCode}, $errorBody',
);
return FlowyResult.failure(
FlowyError(
msg: errorBody['msg'] ?? errorMessage,
),
);
}
} catch (e) {
Log.error('${endpoint.name} request failed: error: $e');
return FlowyResult.failure(
FlowyError(msg: 'Network error: ${e.toString()}'),
);
}
}
}

View file

@ -1,8 +1,11 @@
import 'dart:async';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/shared/af_user_profile_extension.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/member_http_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@ -34,163 +37,260 @@ class WorkspaceMemberBloc
super(WorkspaceMemberState.initial()) {
on<WorkspaceMemberEvent>((event, emit) async {
await event.when(
initial: () async {
await _setCurrentWorkspaceId(workspaceId);
final result = await _userBackendService.getWorkspaceMembers(
_workspaceId,
);
final members = result.fold<List<WorkspaceMemberPB>>(
(s) => s.items,
(e) => [],
);
final myRole = _getMyRole(members);
if (myRole.isOwner) {
unawaited(_fetchWorkspaceSubscriptionInfo());
}
emit(
state.copyWith(
members: members,
myRole: myRole,
isLoading: false,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.get,
result: result,
),
),
);
},
getWorkspaceMembers: () async {
final result = await _userBackendService.getWorkspaceMembers(
_workspaceId,
);
final members = result.fold<List<WorkspaceMemberPB>>(
(s) => s.items,
(e) => [],
);
final myRole = _getMyRole(members);
emit(
state.copyWith(
members: members,
myRole: myRole,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.get,
result: result,
),
),
);
},
addWorkspaceMember: (email) async {
final result = await _userBackendService.addWorkspaceMember(
_workspaceId,
email,
);
emit(
state.copyWith(
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.add,
result: result,
),
),
);
// the addWorkspaceMember doesn't return the updated members,
// so we need to get the members again
result.onSuccess((s) {
add(const WorkspaceMemberEvent.getWorkspaceMembers());
});
},
inviteWorkspaceMember: (email) async {
final result = await _userBackendService.inviteWorkspaceMember(
_workspaceId,
email,
role: AFRolePB.Member,
);
emit(
state.copyWith(
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.invite,
result: result,
),
),
);
},
removeWorkspaceMember: (email) async {
final result = await _userBackendService.removeWorkspaceMember(
_workspaceId,
email,
);
final members = result.fold(
(s) => state.members.where((e) => e.email != email).toList(),
(e) => state.members,
);
emit(
state.copyWith(
members: members,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.remove,
result: result,
),
),
);
},
updateWorkspaceMember: (email, role) async {
final result = await _userBackendService.updateWorkspaceMember(
_workspaceId,
email,
role,
);
final members = result.fold(
(s) => state.members.map((e) {
if (e.email == email) {
e.freeze();
return e.rebuild((p0) => p0.role = role);
}
return e;
}).toList(),
(e) => state.members,
);
emit(
state.copyWith(
members: members,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.updateRole,
result: result,
),
),
);
},
initial: () async => _onInitial(emit, workspaceId),
getWorkspaceMembers: () async => _onGetWorkspaceMembers(emit),
addWorkspaceMember: (email) async => _onAddWorkspaceMember(emit, email),
inviteWorkspaceMemberByEmail: (email) async =>
_onInviteWorkspaceMemberByEmail(emit, email),
removeWorkspaceMemberByEmail: (email) async =>
_onRemoveWorkspaceMemberByEmail(emit, email),
inviteWorkspaceMemberByLink: (link) async =>
_onInviteWorkspaceMemberByLink(emit, link),
generateInviteLink: () async => _onGenerateInviteLink(emit),
updateWorkspaceMember: (email, role) async =>
_onUpdateWorkspaceMember(emit, email, role),
updateSubscriptionInfo: (info) async =>
emit(state.copyWith(subscriptionInfo: info)),
upgradePlan: () async {
final plan = state.subscriptionInfo?.plan;
if (plan == null) {
return Log.error('Failed to upgrade plan: plan is null');
}
if (plan == WorkspacePlanPB.FreePlan) {
final checkoutLink = await _userBackendService.createSubscription(
_workspaceId,
SubscriptionPlanPB.Pro,
);
checkoutLink.fold(
(pl) => afLaunchUrlString(pl.paymentLink),
(f) => Log.error('Failed to create subscription: ${f.msg}', f),
);
}
},
_onUpdateSubscriptionInfo(emit, info),
upgradePlan: () async => _onUpgradePlan(),
);
});
}
final UserProfilePB userProfile;
// if the workspace is null, use the current workspace
final UserWorkspacePB? workspace;
late final String _workspaceId;
final UserBackendService _userBackendService;
MemberHttpService? _memberHttpService;
Future<void> _onInitial(
Emitter<WorkspaceMemberState> emit,
String? workspaceId,
) async {
await _setCurrentWorkspaceId(workspaceId);
final result = await _userBackendService.getWorkspaceMembers(_workspaceId);
final members = result.fold<List<WorkspaceMemberPB>>(
(s) => s.items,
(e) => [],
);
final myRole = _getMyRole(members);
if (myRole.isOwner) {
unawaited(_fetchWorkspaceSubscriptionInfo());
}
final baseUrl = await getAppFlowyCloudUrl();
final authToken = userProfile.authToken;
if (authToken != null) {
_memberHttpService = MemberHttpService(
baseUrl: baseUrl,
authToken: authToken,
);
unawaited(
_memberHttpService?.getInviteCode(workspaceId: _workspaceId).fold(
(s) async {
final inviteLink = await _buildInviteLink(inviteCode: s);
emit(state.copyWith(inviteLink: inviteLink));
},
(e) => Log.error('Failed to get invite code: ${e.msg}', e),
),
);
} else {
Log.error('Failed to get auth token');
}
emit(
state.copyWith(
members: members,
myRole: myRole,
isLoading: false,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.get,
result: result,
),
),
);
}
Future<void> _onGetWorkspaceMembers(
Emitter<WorkspaceMemberState> emit,
) async {
final result = await _userBackendService.getWorkspaceMembers(_workspaceId);
final members = result.fold<List<WorkspaceMemberPB>>(
(s) => s.items,
(e) => [],
);
final myRole = _getMyRole(members);
emit(
state.copyWith(
members: members,
myRole: myRole,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.get,
result: result,
),
),
);
}
Future<void> _onAddWorkspaceMember(
Emitter<WorkspaceMemberState> emit,
String email,
) async {
final result = await _userBackendService.addWorkspaceMember(
_workspaceId,
email,
);
emit(
state.copyWith(
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.addByEmail,
result: result,
),
),
);
// the addWorkspaceMember doesn't return the updated members,
// so we need to get the members again
result.onSuccess((s) {
add(const WorkspaceMemberEvent.getWorkspaceMembers());
});
}
Future<void> _onInviteWorkspaceMemberByEmail(
Emitter<WorkspaceMemberState> emit,
String email,
) async {
final result = await _userBackendService.inviteWorkspaceMember(
_workspaceId,
email,
role: AFRolePB.Member,
);
emit(
state.copyWith(
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.inviteByEmail,
result: result,
),
),
);
}
Future<void> _onRemoveWorkspaceMemberByEmail(
Emitter<WorkspaceMemberState> emit,
String email,
) async {
final result = await _userBackendService.removeWorkspaceMember(
_workspaceId,
email,
);
final members = result.fold(
(s) => state.members.where((e) => e.email != email).toList(),
(e) => state.members,
);
emit(
state.copyWith(
members: members,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.removeByEmail,
result: result,
),
),
);
}
Future<void> _onInviteWorkspaceMemberByLink(
Emitter<WorkspaceMemberState> emit,
String link,
) async {}
Future<void> _onGenerateInviteLink(Emitter<WorkspaceMemberState> emit) async {
final result = await _memberHttpService?.generateInviteCode(
workspaceId: _workspaceId,
);
await result?.fold(
(s) async {
final inviteLink = await _buildInviteLink(inviteCode: s);
emit(
state.copyWith(
inviteLink: inviteLink,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.generateInviteLink,
result: result,
),
),
);
},
(e) async {
Log.error('Failed to generate invite link: ${e.msg}', e);
emit(
state.copyWith(
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.generateInviteLink,
result: result,
),
),
);
},
);
}
Future<void> _onUpdateWorkspaceMember(
Emitter<WorkspaceMemberState> emit,
String email,
AFRolePB role,
) async {
final result = await _userBackendService.updateWorkspaceMember(
_workspaceId,
email,
role,
);
final members = result.fold(
(s) => state.members.map((e) {
if (e.email == email) {
e.freeze();
return e.rebuild((p0) => p0.role = role);
}
return e;
}).toList(),
(e) => state.members,
);
emit(
state.copyWith(
members: members,
actionResult: WorkspaceMemberActionResult(
actionType: WorkspaceMemberActionType.updateRole,
result: result,
),
),
);
}
Future<void> _onUpdateSubscriptionInfo(
Emitter<WorkspaceMemberState> emit,
WorkspaceSubscriptionInfoPB info,
) async {
emit(state.copyWith(subscriptionInfo: info));
}
Future<void> _onUpgradePlan() async {
final plan = state.subscriptionInfo?.plan;
if (plan == null) {
return Log.error('Failed to upgrade plan: plan is null');
}
if (plan == WorkspacePlanPB.FreePlan) {
final checkoutLink = await _userBackendService.createSubscription(
_workspaceId,
SubscriptionPlanPB.Pro,
);
checkoutLink.fold(
(pl) => afLaunchUrlString(pl.paymentLink),
(f) => Log.error('Failed to create subscription: ${f.msg}', f),
);
}
}
AFRolePB _getMyRole(List<WorkspaceMemberPB> members) {
final role = members
@ -222,8 +322,6 @@ class WorkspaceMemberBloc
}
}
// We fetch workspace subscription info lazily as it's not needed in the first
// render of the page.
Future<void> _fetchWorkspaceSubscriptionInfo() async {
final result =
await UserBackendService.getWorkspaceSubscriptionInfo(_workspaceId);
@ -237,6 +335,15 @@ class WorkspaceMemberBloc
(f) => Log.error('Failed to fetch subscription info: ${f.msg}', f),
);
}
Future<String> _buildInviteLink({required String inviteCode}) async {
final baseUrl = await getAppFlowyShareDomain();
final authToken = userProfile.authToken;
if (authToken != null) {
return '$baseUrl/app/invited/$inviteCode';
}
return '';
}
}
@freezed
@ -246,10 +353,15 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent {
GetWorkspaceMembers;
const factory WorkspaceMemberEvent.addWorkspaceMember(String email) =
AddWorkspaceMember;
const factory WorkspaceMemberEvent.inviteWorkspaceMember(String email) =
InviteWorkspaceMember;
const factory WorkspaceMemberEvent.removeWorkspaceMember(String email) =
RemoveWorkspaceMember;
const factory WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(
String email,
) = InviteWorkspaceMemberByEmail;
const factory WorkspaceMemberEvent.removeWorkspaceMemberByEmail(
String email,
) = RemoveWorkspaceMemberByEmail;
const factory WorkspaceMemberEvent.inviteWorkspaceMemberByLink(String link) =
InviteWorkspaceMemberByLink;
const factory WorkspaceMemberEvent.generateInviteLink() = GenerateInviteLink;
const factory WorkspaceMemberEvent.updateWorkspaceMember(
String email,
AFRolePB role,
@ -265,10 +377,12 @@ enum WorkspaceMemberActionType {
none,
get,
// this event will send an invitation to the member
invite,
inviteByEmail,
inviteByLink,
generateInviteLink,
// this event will add the member without sending an invitation
add,
remove,
addByEmail,
removeByEmail,
updateRole,
}
@ -292,6 +406,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState {
@Default(null) WorkspaceMemberActionResult? actionResult,
@Default(true) bool isLoading,
@Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo,
@Default(null) String? inviteLink,
}) = _WorkspaceMemberState;
factory WorkspaceMemberState.initial() => const WorkspaceMemberState();
@ -307,6 +422,7 @@ class WorkspaceMemberState with _$WorkspaceMemberState {
other.members == members &&
other.myRole == myRole &&
other.subscriptionInfo == subscriptionInfo &&
other.inviteLink == inviteLink &&
identical(other.actionResult, actionResult);
}
}

View file

@ -1,22 +1,23 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/inivite_member_by_link.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/invite_member_by_email.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
class WorkspaceMembersPage extends StatelessWidget {
const WorkspaceMembersPage({
@ -38,14 +39,14 @@ class WorkspaceMembersPage extends StatelessWidget {
builder: (context, state) {
return SettingsBody(
title: LocaleKeys.settings_appearance_members_title.tr(),
// Enable it when the backend support admin panel
// descriptionBuilder: _buildDescription,
autoSeparate: false,
children: [
if (state.actionResult != null) ...[
_showMemberLimitWarning(context, state),
const VSpace(16),
],
if (state.myRole.canInvite) ...[
const _InviteMember(),
const InviteMemberByLink(),
const SettingsCategorySpacer(),
const InviteMemberByEmail(),
const SettingsCategorySpacer(),
],
if (state.members.isNotEmpty)
@ -61,104 +62,141 @@ class WorkspaceMembersPage extends StatelessWidget {
);
}
Widget _showMemberLimitWarning(
BuildContext context,
WorkspaceMemberState state,
) {
// We promise that state.actionResult != null before calling
// this method
final actionResult = state.actionResult!.result;
final actionType = state.actionResult!.actionType;
// Enable it when the backend support admin panel
// Widget _buildDescription(BuildContext context) {
// final theme = AppFlowyTheme.of(context);
// return Text.rich(
// TextSpan(
// children: [
// TextSpan(
// text:
// '${LocaleKeys.settings_appearance_members_memberPageDescription1.tr()} ',
// style: theme.textStyle.caption.standard(
// color: theme.textColorScheme.secondary,
// ),
// ),
// TextSpan(
// text: LocaleKeys.settings_appearance_members_adminPanel.tr(),
// style: theme.textStyle.caption.underline(
// color: theme.textColorScheme.secondary,
// ),
// mouseCursor: SystemMouseCursors.click,
// recognizer: TapGestureRecognizer()
// ..onTap = () async {
// final baseUrl = await getAppFlowyCloudUrl();
// await afLaunchUrlString(baseUrl);
// },
// ),
// TextSpan(
// text:
// ' ${LocaleKeys.settings_appearance_members_memberPageDescription2.tr()} ',
// style: theme.textStyle.caption.standard(
// color: theme.textColorScheme.secondary,
// ),
// ),
// ],
// ),
// );
// }
if (actionType == WorkspaceMemberActionType.invite &&
actionResult.isFailure) {
final error = actionResult.getFailure().code;
if (error == ErrorCode.WorkspaceMemberLimitExceeded) {
return Row(
children: [
const FlowySvg(
FlowySvgs.warning_s,
blendMode: BlendMode.dst,
size: Size.square(20),
),
const HSpace(12),
Expanded(
child: RichText(
text: TextSpan(
children: [
if (state.subscriptionInfo?.plan ==
WorkspacePlanPB.ProPlan) ...[
TextSpan(
text: LocaleKeys
.settings_appearance_members_memberLimitExceededPro
.tr(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AFThemeExtension.of(context).strongText,
),
),
WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
// Hardcoded support email, in the future we might
// want to add this to an environment variable
onTap: () async => afLaunchUrlString(
'mailto:support@appflowy.io',
),
child: FlowyText(
LocaleKeys
.settings_appearance_members_memberLimitExceededProContact
.tr(),
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
] else ...[
TextSpan(
text: LocaleKeys
.settings_appearance_members_memberLimitExceeded
.tr(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AFThemeExtension.of(context).strongText,
),
),
WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context
.read<WorkspaceMemberBloc>()
.add(const WorkspaceMemberEvent.upgradePlan()),
child: FlowyText(
LocaleKeys
.settings_appearance_members_memberLimitExceededUpgrade
.tr(),
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
],
],
),
),
),
],
);
}
}
// Widget _showMemberLimitWarning(
// BuildContext context,
// WorkspaceMemberState state,
// ) {
// // We promise that state.actionResult != null before calling
// // this method
// final actionResult = state.actionResult!.result;
// final actionType = state.actionResult!.actionType;
return const SizedBox.shrink();
}
// if (actionType == WorkspaceMemberActionType.inviteByEmail &&
// actionResult.isFailure) {
// final error = actionResult.getFailure().code;
// if (error == ErrorCode.WorkspaceMemberLimitExceeded) {
// return Row(
// children: [
// const FlowySvg(
// FlowySvgs.warning_s,
// blendMode: BlendMode.dst,
// size: Size.square(20),
// ),
// const HSpace(12),
// Expanded(
// child: RichText(
// text: TextSpan(
// children: [
// if (state.subscriptionInfo?.plan ==
// WorkspacePlanPB.ProPlan) ...[
// TextSpan(
// text: LocaleKeys
// .settings_appearance_members_memberLimitExceededPro
// .tr(),
// style: TextStyle(
// fontSize: 14,
// fontWeight: FontWeight.w400,
// color: AFThemeExtension.of(context).strongText,
// ),
// ),
// WidgetSpan(
// child: MouseRegion(
// cursor: SystemMouseCursors.click,
// child: GestureDetector(
// // Hardcoded support email, in the future we might
// // want to add this to an environment variable
// onTap: () async => afLaunchUrlString(
// 'mailto:support@appflowy.io',
// ),
// child: FlowyText(
// LocaleKeys
// .settings_appearance_members_memberLimitExceededProContact
// .tr(),
// fontSize: 14,
// fontWeight: FontWeight.w400,
// color: Theme.of(context).colorScheme.primary,
// ),
// ),
// ),
// ),
// ] else ...[
// TextSpan(
// text: LocaleKeys
// .settings_appearance_members_memberLimitExceeded
// .tr(),
// style: TextStyle(
// fontSize: 14,
// fontWeight: FontWeight.w400,
// color: AFThemeExtension.of(context).strongText,
// ),
// ),
// WidgetSpan(
// child: MouseRegion(
// cursor: SystemMouseCursors.click,
// child: GestureDetector(
// onTap: () => context
// .read<WorkspaceMemberBloc>()
// .add(const WorkspaceMemberEvent.upgradePlan()),
// child: FlowyText(
// LocaleKeys
// .settings_appearance_members_memberLimitExceededUpgrade
// .tr(),
// fontSize: 14,
// fontWeight: FontWeight.w400,
// color: Theme.of(context).colorScheme.primary,
// ),
// ),
// ),
// ),
// ],
// ],
// ),
// ),
// ),
// ],
// );
// }
// }
// return const SizedBox.shrink();
// }
void _showResultDialog(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
@ -170,12 +208,12 @@ class WorkspaceMembersPage extends StatelessWidget {
final result = actionResult.result;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.add) {
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showSnackBarMessage(
context,
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
);
},
(f) {
@ -189,12 +227,12 @@ class WorkspaceMembersPage extends StatelessWidget {
);
},
);
} else if (actionType == WorkspaceMemberActionType.invite) {
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showSnackBarMessage(
context,
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
);
},
(f) {
@ -214,116 +252,27 @@ class WorkspaceMembersPage extends StatelessWidget {
);
},
);
}
}
}
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) {
showToastNotification(
message: 'Invite link generated successfully',
);
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: [
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(
hintText:
LocaleKeys.settings_appearance_members_inviteHint.tr(),
controller: _emailController,
onEditingComplete: _inviteMember,
),
),
),
const HSpace(10.0),
SizedBox(
height: 48.0,
child: IntrinsicWidth(
child: PrimaryRoundedButton(
text: LocaleKeys.settings_appearance_members_sendInvite.tr(),
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
onTap: _inviteMember,
),
),
),
],
),
/* Enable this when the feature is ready
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),
*/
],
);
}
void _inviteMember() {
final email = _emailController.text;
if (!isEmail(email)) {
return showSnackBarMessage(
context,
LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
// copy the invite link to the clipboard
final inviteLink = state.inviteLink;
if (inviteLink != null) {
getIt<ClipboardService>().setPlainText(inviteLink);
}
},
(f) {
Log.error('generate invite link failed: $f');
showToastNotification(
message: 'Failed to generate invite link',
);
},
);
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMember(email));
// clear the email field after inviting
_emailController.clear();
}
}
@ -340,9 +289,12 @@ class _MemberList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => const Divider(),
separatorBuilder: () => Divider(
color: theme.borderColorScheme.primary,
),
children: [
const _MemberListHeader(),
...members.map(
@ -362,31 +314,34 @@ class _MemberListHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
final theme = AppFlowyTheme.of(context);
return Row(
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: Text(
LocaleKeys.settings_appearance_members_user.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
Expanded(
child: FlowyText.semibold(
LocaleKeys.settings_appearance_members_role.tr(),
fontSize: 14.0,
),
),
const HSpace(28.0),
],
),
),
Expanded(
child: Text(
LocaleKeys.settings_appearance_members_role.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
Expanded(
child: Text(
LocaleKeys.settings_accountPage_email_title.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
const HSpace(28.0),
],
);
}
@ -405,27 +360,42 @@ class _MemberItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final textColor = member.role.isOwner ? Theme.of(context).hintColor : null;
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Expanded(
child: FlowyText.medium(
child: Text(
member.name,
color: textColor,
fontSize: 14.0,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
),
),
Expanded(
child: member.role.isOwner || !myRole.canUpdate
? FlowyText.medium(
? Text(
member.role.description,
color: textColor,
fontSize: 14.0,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
)
: _MemberRoleActionList(
member: member,
),
),
Expanded(
child: FlowyTooltip(
message: member.email,
child: Text(
member.email,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
),
),
myRole.canDelete &&
member.email != userProfile.email // can't delete self
? _MemberMoreActionList(member: member)
@ -476,7 +446,7 @@ class _MemberMoreActionList extends StatelessWidget {
.settings_appearance_members_areYouSureToRemoveMember
.tr(),
onOkPressed: () => context.read<WorkspaceMemberBloc>().add(
WorkspaceMemberEvent.removeWorkspaceMember(
WorkspaceMemberEvent.removeWorkspaceMemberByEmail(
action.member.email,
),
),
@ -515,106 +485,12 @@ class _MemberRoleActionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopoverActionList<_MemberRoleActionWrapper>(
asBarrier: true,
direction: PopoverDirection.bottomWithLeftAligned,
actions: [AFRolePB.Member]
.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,
),
],
final theme = AppFlowyTheme.of(context);
return Text(
member.role.description,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
);
}
@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');
}
}

View file

@ -606,6 +606,7 @@ Future<void> showConfirmDialog({
VoidCallback? onCancel,
String? confirmLabel,
ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk,
WidgetBuilder? confirmButtonBuilder,
}) {
return showDialog(
context: context,
@ -619,6 +620,7 @@ Future<void> showConfirmDialog({
child: ConfirmPopup(
title: title,
description: description,
confirmButtonBuilder: confirmButtonBuilder,
onConfirm: () => onConfirm?.call(),
onCancel: () => onCancel?.call(),
confirmLabel: confirmLabel,

View file

@ -132,7 +132,7 @@ class _AnimatedThemeState
@override
Widget build(BuildContext context) {
return AppFlowyTheme(
data: data!.evaluate(animation),
data: widget.data,
child: widget.child,
);
}

View file

@ -57,6 +57,7 @@ class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {
);
final borderColorScheme = AppFlowyBorderColorScheme(
primary: AppFlowyPrimitiveTokens.neutral200,
greyPrimary: AppFlowyPrimitiveTokens.neutral1000,
greyPrimaryHover: AppFlowyPrimitiveTokens.neutral900,
greySecondary: AppFlowyPrimitiveTokens.neutral800,
@ -211,6 +212,7 @@ class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {
);
final borderColorScheme = AppFlowyBorderColorScheme(
primary: AppFlowyPrimitiveTokens.neutral800,
greyPrimary: AppFlowyPrimitiveTokens.neutral100,
greyPrimaryHover: AppFlowyPrimitiveTokens.neutral200,
greySecondary: AppFlowyPrimitiveTokens.neutral300,

View file

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
class AppFlowyBorderColorScheme {
const AppFlowyBorderColorScheme({
AppFlowyBorderColorScheme({
required this.primary,
required this.greyPrimary,
required this.greyPrimaryHover,
required this.greySecondary,
@ -25,6 +26,7 @@ class AppFlowyBorderColorScheme {
required this.purpleThickHover,
});
final Color primary;
final Color greyPrimary;
final Color greyPrimaryHover;
final Color greySecondary;
@ -52,6 +54,7 @@ class AppFlowyBorderColorScheme {
double t,
) {
return AppFlowyBorderColorScheme(
primary: Color.lerp(primary, other.primary, t)!,
greyPrimary: Color.lerp(greyPrimary, other.greyPrimary, t)!,
greyPrimaryHover:
Color.lerp(greyPrimaryHover, other.greyPrimaryHover, t)!,

View file

@ -1,4 +1,5 @@
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -29,14 +30,17 @@ void main() {
showDialog(
context: context,
builder: (_) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: ConfirmPopup(
description: "desc",
title: "title",
onConfirm: onConfirm,
return AppFlowyTheme(
data: AppFlowyDefaultTheme().light(),
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: ConfirmPopup(
description: "desc",
title: "title",
onConfirm: onConfirm,
),
),
);
},

View file

@ -1307,10 +1307,10 @@
"showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page",
"enableRTLToolbarItems": "Enable RTL toolbar items",
"members": {
"title": "Members settings",
"title": "Members",
"inviteMembers": "Invite members",
"inviteHint": "Invite by email",
"sendInvite": "Send invite",
"sendInvite": "Invite",
"copyInviteLink": "Copy invite link",
"label": "Members",
"user": "User",
@ -1345,7 +1345,22 @@
"inviteMemberSuccess": "The invitation has been sent successfully",
"failedToInviteMember": "Failed to invite member",
"workspaceMembersError": "Oops, something went wrong",
"workspaceMembersErrorDescription": "We couldn't load the member list at this time. Please try again later"
"workspaceMembersErrorDescription": "We couldn't load the member list at this time. Please try again later",
"inviteLinkToAddMember": "Invite link to add member",
"clickToCopyLink": "Click to copy link",
"or": "or",
"generateANewLink": "generate a new link",
"inviteMemberByEmail": "Invite member by email",
"inviteMemberHintText": "Invite by email",
"resetInviteLink": "Reset the invite link",
"resetInviteLinkDescription": "Resetting will deactivate the current link for all space members and generate a new one. The previous link can only be managed through the",
"adminPanel": "Admin Panel",
"reset": "Reset",
"resetInviteLinkSuccess": "Invite link reset successfully",
"resetInviteLinkFailed": "Failed to reset the invite link",
"resetInviteLinkFailedDescription": "Please try again later",
"memberPageDescription1": "Access the",
"memberPageDescription2": "for guest and advanced user management."
}
},
"files": {