mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-04-24 06:37:14 -04:00
Merge branch 'main' into integrate_workspace_template
This commit is contained in:
commit
c10c844fa9
22 changed files with 1141 additions and 608 deletions
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -178,7 +178,7 @@ class _MemberItem extends StatelessWidget {
|
|||
showBottomBorder: false,
|
||||
onTap: () {
|
||||
workspaceMemberBloc.add(
|
||||
WorkspaceMemberEvent.removeWorkspaceMember(
|
||||
WorkspaceMemberEvent.removeWorkspaceMemberByEmail(
|
||||
member.email,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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()}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -132,7 +132,7 @@ class _AnimatedThemeState
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyTheme(
|
||||
data: data!.evaluate(animation),
|
||||
data: widget.data,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)!,
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue