feat: support invite member by link on mobile (#7811)

* fix: invitation link issues

* fix: cargo fmt

* feat: support invite member by link on mobile

* feat: implement new settings page design on mobile

* feat: add leave workspace button

* fix: flutter analyze

* fix: bloc error

* feat: add addMembers page
This commit is contained in:
Lucas 2025-04-24 10:28:18 +08:00 committed by GitHub
parent 780ba51d87
commit 6a887fdca9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1305 additions and 312 deletions

View File

@ -1,9 +1,9 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/tasks/device_info_task.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -22,23 +22,23 @@ class AboutSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_mobile_privacyPolicy.tr(),
trailing: const Icon(
Icons.chevron_right,
trailing: MobileSettingTrailing(
text: '',
),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
trailing: MobileSettingTrailing(
text: '',
),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
),
if (kDebugMode)
MobileSettingItem(
name: 'Feature Flags',
trailing: const Icon(
Icons.chevron_right,
trailing: MobileSettingTrailing(
text: '',
),
onTap: () {
context.push(FeatureFlagScreen.routeName);
@ -46,9 +46,10 @@ class AboutSettingGroup extends StatelessWidget {
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_version.tr(),
trailing: FlowyText(
'${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
color: Theme.of(context).colorScheme.onSurface,
trailing: MobileSettingTrailing(
text:
'${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
showArrow: false,
),
),
],

View File

@ -2,11 +2,11 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -23,7 +23,6 @@ class AiSettingsGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider(
create: (context) => SettingsAIBloc(
userProfile,
@ -36,21 +35,8 @@ class AiSettingsGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
trailing: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText(
state.availableModels?.selectedModel.name ?? "",
color: theme.colorScheme.onSurface,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.chevron_right),
],
),
trailing: MobileSettingTrailing(
text: state.availableModels?.selectedModel.name ?? "",
),
onTap: () => _onLLMModelTypeTap(context, state),
),

View File

@ -1,10 +1,10 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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';
@ -17,20 +17,12 @@ class RTLSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textDirection =
context.watch<AppearanceSettingsCubit>().state.textDirection;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_textDirection_label.tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
_textDirectionLabelText(textDirection),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
trailing: MobileSettingTrailing(
text: _textDirectionLabelText(textDirection),
),
onTap: () {
showMobileBottomSheet(

View File

@ -1,11 +1,11 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:scaled_app/scaled_app.dart';
@ -42,18 +42,10 @@ class _DisplaySizeSettingState extends State<DisplaySizeSetting> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return MobileSettingItem(
name: LocaleKeys.settings_appearance_displaySize.tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
scaleFactor.toStringAsFixed(2),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
trailing: MobileSettingTrailing(
text: scaleFactor.toStringAsFixed(1),
),
onTap: () {
showMobileBottomSheet(

View File

@ -1,11 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/util/theme_mode_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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';
@ -18,19 +18,11 @@ class ThemeSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final themeMode = context.watch<AppearanceSettingsCubit>().state.themeMode;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_themeMode_label.tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
themeMode.labelText,
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
trailing: MobileSettingTrailing(
text: themeMode.labelText,
),
onTap: () {
showMobileBottomSheet(

View File

@ -1,11 +1,11 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
class CloudSettingGroup extends StatelessWidget {
const CloudSettingGroup({
@ -15,19 +15,23 @@ class CloudSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => MobileSettingGroup(
groupTitle: LocaleKeys.settings_menu_cloudSettings.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_menu_cloudAppFlowy.tr(),
trailing: const Icon(
Icons.chevron_right,
future: getAuthenticatorType(),
builder: (context, snapshot) {
final cloudType = snapshot.data ?? AuthenticatorType.appflowyCloud;
final name = titleFromCloudType(cloudType);
return MobileSettingGroup(
groupTitle: 'Cloud settings',
settingItemList: [
MobileSettingItem(
name: 'Cloud server',
trailing: MobileSettingTrailing(
text: name,
),
onTap: () => context.push(AppFlowyCloudPage.routeName),
),
onTap: () => context.push(AppFlowyCloudPage.routeName),
),
],
),
],
);
},
);
}
}

View File

@ -2,11 +2,11 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/util/font_family_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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:go_router/go_router.dart';
@ -20,21 +20,12 @@ class FontSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectedFont = context.watch<AppearanceSettingsCubit>().state.font;
final name = selectedFont.fontFamilyDisplayName;
return MobileSettingItem(
name: LocaleKeys.settings_appearance_fontFamily_label.tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
name,
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
trailing: MobileSettingTrailing(
text: name,
),
onTap: () async {
final newFont = await context.push<String>(FontPickerScreen.routeName);

View File

@ -1,9 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/language.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -28,22 +28,13 @@ class _LanguageSettingGroupState extends State<LanguageSettingGroup> {
return state.locale;
},
builder: (context, locale) {
final theme = Theme.of(context);
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_menu_language.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_menu_language.tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
languageFromLocale(locale),
color: theme.colorScheme.onSurface,
),
const Icon(Icons.chevron_right),
],
trailing: MobileSettingTrailing(
text: languageFromLocale(locale),
),
onTap: () async {
final newLocale =

View File

@ -1,9 +1,10 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/user/prelude.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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -21,7 +22,7 @@ class PersonalInfoSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final theme = AppFlowyTheme.of(context);
return BlocProvider<SettingsUserViewBloc>(
create: (context) => getIt<SettingsUserViewBloc>(
param1: userProfile,
@ -33,16 +34,10 @@ class PersonalInfoSettingGroup extends StatelessWidget {
groupTitle: LocaleKeys.settings_accountPage_title.tr(),
settingItemList: [
MobileSettingItem(
name: userName,
subtitle: isAuthEnabled && userProfile.email.isNotEmpty
? Text(
userProfile.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
)
: null,
trailing: const Icon(Icons.chevron_right),
name: 'User name',
trailing: MobileSettingTrailing(
text: userName,
),
onTap: () {
showMobileBottomSheet(
context,
@ -64,6 +59,15 @@ class PersonalInfoSettingGroup extends StatelessWidget {
);
},
),
MobileSettingItem(
name: 'Email',
trailing: Text(
userProfile.email,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
),
),
],
);
},

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/appflowy_cache_manager.dart';
import 'package:appflowy/startup/startup.dart';
@ -30,15 +31,15 @@ class SupportSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_mobile_joinDiscord.tr(),
trailing: const Icon(
Icons.chevron_right,
trailing: MobileSettingTrailing(
text: '',
),
onTap: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'),
),
MobileSettingItem(
name: LocaleKeys.workspace_errorActions_reportIssue.tr(),
trailing: const Icon(
Icons.chevron_right,
trailing: MobileSettingTrailing(
text: '',
),
onTap: () {
showMobileBottomSheet(
@ -57,8 +58,8 @@ class SupportSettingGroup extends StatelessWidget {
),
MobileSettingItem(
name: LocaleKeys.settings_files_clearCache.tr(),
trailing: const Icon(
Icons.chevron_right,
trailing: MobileSettingTrailing(
text: '',
),
onTap: () async {
await showFlowyMobileConfirmDialog(

View File

@ -9,6 +9,7 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widget
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
import 'package:appflowy_backend/log.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_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@ -26,6 +27,7 @@ class UserSessionSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
children: [
// third party sign in buttons
@ -41,11 +43,15 @@ class UserSessionSettingGroup extends StatelessWidget {
// delete account button
// only show the delete account button in cloud mode
if (userProfile.workspaceAuthType == AuthTypePB.Server) ...[
const VSpace(16.0),
MobileLogoutButton(
VSpace(theme.spacing.l),
AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.button_deleteAccount.tr(),
textColor: Theme.of(context).colorScheme.error,
onPressed: () => _showDeleteAccountDialog(context),
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.error,
),
onTap: () => _showDeleteAccountDialog(context),
size: AFButtonSize.l,
),
],
],

View File

@ -1,3 +1,4 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@ -15,16 +16,22 @@ class MobileSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(4.0),
FlowyText.semibold(
VSpace(theme.spacing.s),
Text(
groupTitle,
style: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
),
const VSpace(4.0),
VSpace(theme.spacing.s),
...settingItemList,
showDivider ? const Divider() : const SizedBox.shrink(),
showDivider
? AFDivider(spacing: theme.spacing.m)
: const SizedBox.shrink(),
],
);
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@ -26,17 +27,18 @@ class MobileSettingItem extends StatelessWidget {
return Padding(
padding: padding,
child: ListTile(
title: title ?? _buildDefaultTitle(name),
title: title ?? _buildDefaultTitle(context, name),
subtitle: subtitle,
trailing: trailing,
onTap: onTap,
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0),
contentPadding: EdgeInsets.zero,
),
);
}
Widget _buildDefaultTitle(String? name) {
Widget _buildDefaultTitle(BuildContext context, String? name) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
if (leadingIcon != null) ...[
@ -44,9 +46,11 @@ class MobileSettingItem extends StatelessWidget {
const HSpace(8),
],
Expanded(
child: FlowyText.medium(
child: Text(
name ?? '',
fontSize: 14.0,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),

View File

@ -0,0 +1,42 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class MobileSettingTrailing extends StatelessWidget {
const MobileSettingTrailing({
super.key,
required this.text,
this.showArrow = true,
});
final String text;
final bool showArrow;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
text,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
),
),
if (showArrow) ...[
const HSpace(8),
FlowySvg(
FlowySvgs.toolbar_arrow_right_m,
size: Size.square(24),
color: theme.iconColorScheme.tertiary,
),
],
],
);
}
}

View File

@ -0,0 +1,349 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.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/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_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_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.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 'member_list.dart';
class AddMembersScreen extends StatelessWidget {
const AddMembersScreen({
super.key,
});
static const routeName = '/add_member';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: 'Add members',
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
}
class _InviteMemberPage extends StatefulWidget {
const _InviteMemberPage();
@override
State<_InviteMemberPage> createState() => _InviteMemberPageState();
}
class _InviteMemberPageState extends State<_InviteMemberPage> {
final emailController = TextEditingController();
late final Future<UserProfilePB?> userProfile;
bool exceededLimit = false;
@override
void initState() {
super.initState();
userProfile = UserBackendService.getCurrentUserProfile().fold(
(s) => s,
(f) => null,
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
if (snapshot.hasError || snapshot.data == null) {
return _buildError(context);
}
final userProfile = snapshot.data!;
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return Column(
children: [
if (state.myRole.isOwner) ...[
Container(
width: double.infinity,
padding: EdgeInsets.all(theme.spacing.xl),
child: const MInviteMemberByEmail(),
),
VSpace(theme.spacing.m),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
if (state.myRole.isMember) ...[
Spacer(),
const _LeaveWorkspaceButton(),
],
const VSpace(48),
],
);
},
),
);
},
);
}
// Widget _buildInviteMemberArea(BuildContext context) {
// return Column(
// children: [
// TextFormField(
// autofocus: true,
// controller: emailController,
// keyboardType: TextInputType.text,
// decoration: InputDecoration(
// hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
// ),
// ),
// const VSpace(16),
// if (exceededLimit) ...[
// FlowyText.regular(
// LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
// .tr(),
// fontSize: 14.0,
// maxLines: 3,
// color: Theme.of(context).colorScheme.error,
// ),
// const VSpace(16),
// ],
// SizedBox(
// width: double.infinity,
// child: ElevatedButton(
// onPressed: () => _inviteMember(context),
// child: Text(
// LocaleKeys.settings_appearance_members_sendInvite.tr(),
// ),
// ),
// ),
// ],
// );
// }
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.medium(
LocaleKeys.settings_appearance_members_workspaceMembersError.tr(),
fontSize: 18.0,
textAlign: TextAlign.center,
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys
.settings_appearance_members_workspaceMembersErrorDescription
.tr(),
fontSize: 17.0,
maxLines: 10,
textAlign: TextAlign.center,
lineHeight: 1.3,
color: Theme.of(context).hintColor,
),
],
),
),
);
}
void _onListener(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// get keyboard height
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
bottomPadding: keyboardHeight,
);
},
);
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) {
showToastNotification(
message: 'Invite link generated successfully',
);
// 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',
);
},
);
}
}
// void _inviteMember(BuildContext context) {
// 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();
// }
}
class _LeaveWorkspaceButton extends StatelessWidget {
const _LeaveWorkspaceButton();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
onTap: () => _leaveWorkspace(context),
size: AFButtonSize.l,
),
);
}
void _leaveWorkspace(BuildContext context) {
showFlowyCupertinoConfirmDialog(
title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_confirm.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (buttonContext) async {},
);
}
}

View File

@ -0,0 +1,356 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.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';
import 'member_list.dart';
ValueNotifier<int> mobileLeaveWorkspaceNotifier = ValueNotifier(0);
class InviteMembersScreen extends StatelessWidget {
const InviteMembersScreen({
super.key,
});
static const routeName = '/invite_member';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: LocaleKeys.settings_appearance_members_label.tr(),
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
}
class _InviteMemberPage extends StatefulWidget {
const _InviteMemberPage();
@override
State<_InviteMemberPage> createState() => _InviteMemberPageState();
}
class _InviteMemberPageState extends State<_InviteMemberPage> {
final emailController = TextEditingController();
late final Future<UserProfilePB?> userProfile;
bool exceededLimit = false;
@override
void initState() {
super.initState();
userProfile = UserBackendService.getCurrentUserProfile().fold(
(s) => s,
(f) => null,
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
if (snapshot.hasError || snapshot.data == null) {
return _buildError(context);
}
final userProfile = snapshot.data!;
return BlocProvider<WorkspaceMemberBloc>(
create: (context) => WorkspaceMemberBloc(userProfile: userProfile)
..add(const WorkspaceMemberEvent.initial()),
child: BlocConsumer<WorkspaceMemberBloc, WorkspaceMemberState>(
listener: _onListener,
builder: (context, state) {
return Column(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.myRole.isOwner) ...[
Padding(
padding: EdgeInsets.all(theme.spacing.xl),
child: _buildInviteMemberArea(context),
),
const VSpace(16),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
],
),
),
if (state.myRole.isMember) const _LeaveWorkspaceButton(),
const VSpace(48),
],
);
},
),
);
},
);
}
Widget _buildInviteMemberArea(BuildContext context) {
return Column(
children: [
TextFormField(
autofocus: true,
controller: emailController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
),
),
const VSpace(16),
if (exceededLimit) ...[
FlowyText.regular(
LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
.tr(),
fontSize: 14.0,
maxLines: 3,
color: Theme.of(context).colorScheme.error,
),
const VSpace(16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _inviteMember(context),
child: Text(
LocaleKeys.settings_appearance_members_sendInvite.tr(),
),
),
),
],
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText.medium(
LocaleKeys.settings_appearance_members_workspaceMembersError.tr(),
fontSize: 18.0,
textAlign: TextAlign.center,
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys
.settings_appearance_members_workspaceMembersErrorDescription
.tr(),
fontSize: 17.0,
maxLines: 10,
textAlign: TextAlign.center,
lineHeight: 1.3,
color: Theme.of(context).hintColor,
),
],
),
),
);
}
void _onListener(BuildContext context, WorkspaceMemberState state) {
final actionResult = state.actionResult;
if (actionResult == null) {
return;
}
final actionType = actionResult.actionType;
final result = actionResult.result;
// get keyboard height
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('add workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToAddMember.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
Log.error('invite workspace member failed: $f');
final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded
? LocaleKeys
.settings_appearance_members_inviteFailedMemberLimitMobile
.tr()
: LocaleKeys.settings_appearance_members_failedToInviteMember
.tr();
setState(() {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
result.fold(
(s) {
showToastNotification(
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
bottomPadding: keyboardHeight,
);
},
(f) {
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
.tr(),
bottomPadding: keyboardHeight,
);
},
);
}
}
void _inviteMember(BuildContext context) {
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();
}
}
class _LeaveWorkspaceButton extends StatelessWidget {
const _LeaveWorkspaceButton();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 0.5,
),
),
),
onPressed: () => _leaveWorkspace(context),
child: FlowyText(
LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
fontSize: 14.0,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
);
}
void _leaveWorkspace(BuildContext context) {
showFlowyCupertinoConfirmDialog(
title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_confirm.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (buttonContext) async {
// try to use popUntil with a specific route name but failed
// so use pop twice as a workaround
Navigator.of(buttonContext).pop();
Navigator.of(context).pop();
Navigator.of(context).pop();
mobileLeaveWorkspaceNotifier.value =
mobileLeaveWorkspaceNotifier.value + 1;
},
);
}
}

View File

@ -1,19 +1,25 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/add_members_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.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/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.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';
import 'package:go_router/go_router.dart';
import 'member_list.dart';
@ -31,11 +37,26 @@ class InviteMembersScreen extends StatelessWidget {
return Scaffold(
appBar: FlowyAppBar(
titleText: LocaleKeys.settings_appearance_members_label.tr(),
actions: [
_buildAddMemberButton(context),
],
),
body: const _InviteMemberPage(),
resizeToAvoidBottomInset: false,
);
}
Widget _buildAddMemberButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 20),
child: GestureDetector(
onTap: () {
context.push(AddMembersScreen.routeName);
},
child: FlowySvg(FlowySvgs.add_thin_s),
),
);
}
}
class _InviteMemberPage extends StatefulWidget {
@ -67,6 +88,7 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder(
future: userProfile,
builder: (context, snapshot) {
@ -87,29 +109,27 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
builder: (context, state) {
return Column(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.myRole.isOwner) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildInviteMemberArea(context),
),
const VSpace(16),
],
if (state.members.isNotEmpty) ...[
const VSpace(8),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
],
if (state.myRole.isOwner) ...[
Container(
width: double.infinity,
padding: EdgeInsets.all(theme.spacing.xl),
child: const MInviteMemberByLink(),
),
),
if (state.myRole.isMember) const _LeaveWorkspaceButton(),
VSpace(theme.spacing.m),
],
if (state.members.isNotEmpty) ...[
const AFDivider(),
VSpace(theme.spacing.xl),
MobileMemberList(
members: state.members,
userProfile: userProfile,
myRole: state.myRole,
),
],
if (state.myRole.isMember) ...[
Spacer(),
const _LeaveWorkspaceButton(),
],
const VSpace(48),
],
);
@ -120,41 +140,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
);
}
Widget _buildInviteMemberArea(BuildContext context) {
return Column(
children: [
TextFormField(
autofocus: true,
controller: emailController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
),
),
const VSpace(16),
if (exceededLimit) ...[
FlowyText.regular(
LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile
.tr(),
fontSize: 14.0,
maxLines: 3,
color: Theme.of(context).colorScheme.error,
),
const VSpace(16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _inviteMember(context),
child: Text(
LocaleKeys.settings_appearance_members_sendInvite.tr(),
),
),
),
],
);
}
Widget _buildError(BuildContext context) {
return Center(
child: Padding(
@ -270,23 +255,28 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
);
},
);
}
}
} else if (actionType == WorkspaceMemberActionType.generateInviteLink) {
result.fold(
(s) {
showToastNotification(
message: 'Invite link generated successfully',
);
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
showToastNotification(
type: ToastificationType.error,
message: 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(
type: ToastificationType.error,
message: 'Failed to generate invite link',
);
},
);
return;
}
context
.read<WorkspaceMemberBloc>()
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}
}
@ -295,28 +285,13 @@ class _LeaveWorkspaceButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 0.5,
),
),
),
onPressed: () => _leaveWorkspace(context),
child: FlowyText(
LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
fontSize: 14.0,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AFOutlinedTextButton.destructive(
alignment: Alignment.center,
text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(),
onTap: () => _leaveWorkspace(context),
size: AFButtonSize.l,
),
);
}

View File

@ -5,6 +5,7 @@ import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.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_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@ -27,31 +28,34 @@ class MobileMemberList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SlidableAutoCloseBehavior(
child: SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => const FlowyDivider(
padding: EdgeInsets.symmetric(horizontal: 16.0),
final theme = AppFlowyTheme.of(context);
return SingleChildScrollView(
child: SlidableAutoCloseBehavior(
child: SeparatedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: () => SizedBox.shrink(),
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Text(
'Joined',
style: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
),
),
...members.map(
(member) => _MemberItem(
member: member,
myRole: myRole,
userProfile: userProfile,
),
),
],
),
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FlowyText.semibold(
LocaleKeys.settings_appearance_members_label.tr(),
fontSize: 16.0,
),
),
...members.map(
(member) => _MemberItem(
member: member,
myRole: myRole,
userProfile: userProfile,
),
),
],
),
);
}
@ -70,8 +74,8 @@ class _MemberItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final canDelete = myRole.canDelete && member.email != userProfile.email;
final textColor = member.role.isOwner ? Theme.of(context).hintColor : null;
Widget child;
@ -79,17 +83,19 @@ class _MemberItem extends StatelessWidget {
child = Row(
children: [
Expanded(
child: FlowyText.medium(
child: Text(
member.name,
color: textColor,
fontSize: 15.0,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
Expanded(
child: FlowyText.medium(
child: Text(
member.role.description,
color: textColor,
fontSize: 15.0,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
textAlign: TextAlign.end,
),
),
@ -99,18 +105,19 @@ class _MemberItem extends StatelessWidget {
child = Row(
children: [
Expanded(
child: FlowyText.medium(
child: Text(
member.name,
color: textColor,
fontSize: 15.0,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
const HSpace(36.0),
FlowyText.medium(
Text(
member.role.description,
color: textColor,
fontSize: 15.0,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.secondary,
),
textAlign: TextAlign.end,
),
],
@ -118,8 +125,10 @@ class _MemberItem extends StatelessWidget {
}
child = Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.xl,
vertical: theme.spacing.l,
),
child: child,
);

View File

@ -1,7 +1,10 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../widgets/widgets.dart';
import 'invite_members_screen.dart';
@ -13,12 +16,21 @@ class WorkspaceSettingGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final memberCount = context
.read<UserWorkspaceBloc>()
.state
.currentWorkspace
?.memberCount
.toString() ??
'';
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_appearance_members_label.tr(),
settingItemList: [
MobileSettingItem(
name: LocaleKeys.settings_appearance_members_label.tr(),
trailing: const Icon(Icons.chevron_right),
trailing: MobileSettingTrailing(
text: memberCount,
),
onTap: () {
context.push(InviteMembersScreen.routeName);
},

View File

@ -119,7 +119,9 @@ class MentionPageBloc extends Bloc<MentionPageEvent, MentionPageState> {
final trash = trashOrFailed.toNullable();
if (trash != null) {
final isInTrash = trash.any((t) => t.id == pageId);
add(MentionPageEvent.didUpdateTrashStatus(isInTrash: isInTrash));
if (!isClosed) {
add(MentionPageEvent.didUpdateTrashStatus(isInTrash: isInTrash));
}
}
},
);

View File

@ -84,6 +84,9 @@ class FlowyRunner {
IntegrationTestHelper.rustEnvsBuilder = rustEnvsBuilder;
}
// Disable the log in test mode
Log.shared.disableLog = mode.isTest;
// Clear and dispose tasks from previous AppLaunch
if (getIt.isRegistered(instance: AppLauncher)) {
await getIt<AppLauncher>().dispose();

View File

@ -18,6 +18,7 @@ import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.d
import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart';
import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/add_members_screen.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart';
import 'package:appflowy/plugins/base/color/color_picker_screen.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
@ -104,6 +105,7 @@ GoRouter generateRouter(Widget child) {
// invite members
_mobileInviteMembersPageRoute(),
_mobileAddMembersPageRoute(),
],
// Desktop and Mobile
@ -215,6 +217,16 @@ GoRoute _mobileInviteMembersPageRoute() {
);
}
GoRoute _mobileAddMembersPageRoute() {
return GoRoute(
parentNavigatorKey: AppGlobals.rootNavKey,
path: AddMembersScreen.routeName,
pageBuilder: (context, state) {
return const MaterialExtendedPage(child: AddMembersScreen());
},
);
}
GoRoute _mobileCloudSettingAppFlowyCloudPageRoute() {
return GoRoute(
parentNavigatorKey: AppGlobals.rootNavKey,

View File

@ -1,36 +1,25 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class MobileLogoutButton extends StatelessWidget {
const MobileLogoutButton({
super.key,
this.icon,
required this.text,
this.textColor,
required this.onPressed,
});
final FlowySvgData? icon;
final String text;
final Color? textColor;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return AFOutlinedIconTextButton.normal(
return AFOutlinedTextButton.normal(
alignment: Alignment.center,
text: text,
onTap: onPressed,
size: AFButtonSize.l,
iconBuilder: (context, isHovering, disabled) {
if (icon == null) {
return const SizedBox.shrink();
}
return FlowySvg(
icon!,
size: Size.square(18),
);
},
);
}
}

View File

@ -189,6 +189,7 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.end,
children: [
AFOutlinedTextButton.normal(
size: UniversalPlatform.isDesktop ? AFButtonSize.m : AFButtonSize.l,
text: LocaleKeys.button_cancel.tr(),
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
@ -335,6 +336,7 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
@ -353,24 +355,28 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
}
},
child: Container(
padding: const EdgeInsets.all(20),
color: UniversalPlatform.isDesktop
? null
: Theme.of(context).colorScheme.surface,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.xl),
color: AppFlowyTheme.of(context).surfaceColorScheme.primary,
),
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.xxl,
vertical: theme.spacing.xxl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
if (widget.description.isNotEmpty) ...[
const VSpace(6),
VSpace(theme.spacing.l),
_buildDescription(),
],
if (widget.child != null) ...[
const VSpace(12),
widget.child!,
],
const VSpace(20),
VSpace(theme.spacing.xxl),
_buildStyledButton(context),
],
),

View File

@ -0,0 +1,94 @@
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 MInviteMemberByEmail extends StatefulWidget {
const MInviteMemberByEmail({super.key});
@override
State<MInviteMemberByEmail> createState() => _MInviteMemberByEmailState();
}
class _MInviteMemberByEmailState extends State<MInviteMemberByEmail> {
final _emailController = TextEditingController();
bool _isInviteButtonEnabled = false;
@override
void initState() {
super.initState();
_emailController.addListener(_onEmailChanged);
}
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AFTextField(
autoFocus: true,
controller: _emailController,
hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(),
onSubmitted: (value) => _inviteMember(),
),
VSpace(theme.spacing.m),
_isInviteButtonEnabled
? AFFilledTextButton.primary(
text: 'Send invite',
alignment: Alignment.center,
size: AFButtonSize.l,
textStyle: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.onFill,
),
onTap: _inviteMember,
)
: AFFilledTextButton.disabled(
text: 'Send invite',
alignment: Alignment.center,
size: AFButtonSize.l,
textStyle: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.tertiary,
),
),
],
);
}
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();
}
void _onEmailChanged() {
setState(() {
_isInviteButtonEnabled = _emailController.text.isNotEmpty;
});
}
}

View File

@ -0,0 +1,155 @@
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:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
class MInviteMemberByLink extends StatelessWidget {
const MInviteMemberByLink({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Title(),
VSpace(theme.spacing.l),
_CopyLinkButton(),
VSpace(theme.spacing.l),
_Description(),
],
);
}
}
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.heading4.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.body.standard(
color: theme.textColorScheme.primary,
),
),
TextSpan(
text: ' ${LocaleKeys.settings_appearance_members_or.tr()} ',
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
TextSpan(
text: LocaleKeys.settings_appearance_members_generateANewLink.tr(),
style: theme.textStyle.body.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: LocaleKeys.settings_appearance_members_resetInviteLink.tr(),
description: LocaleKeys
.settings_appearance_members_resetInviteLinkDescription
.tr(),
confirmLabel: LocaleKeys.settings_appearance_members_reset.tr(),
onConfirm: () {
context.read<WorkspaceMemberBloc>().add(
const WorkspaceMemberEvent.generateInviteLink(),
);
},
confirmButtonBuilder: (dialogContext) => AFFilledTextButton.destructive(
size: UniversalPlatform.isDesktop ? AFButtonSize.m : AFButtonSize.l,
text: LocaleKeys.settings_appearance_members_reset.tr(),
onTap: () {
context.read<WorkspaceMemberBloc>().add(
const WorkspaceMemberEvent.generateInviteLink(),
);
Navigator.of(dialogContext).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(
size: AFButtonSize.l,
alignment: Alignment.center,
text: LocaleKeys.button_copyLink.tr(),
textStyle: theme.textStyle.heading4.enhanced(
color: theme.textColorScheme.primary,
),
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: 'You haven\'t generated an invite link yet.',
type: ToastificationType.error,
);
}
},
);
}
}

View File

@ -66,7 +66,15 @@ class MemberHttpService {
try {
return result.fold(
(data) => FlowyResult.success(data['code'] as String),
(data) {
final code = data['data']['code'] as String;
if (code.isEmpty) {
return FlowyResult.failure(
FlowyError(msg: 'Failed to get invite code: $code'),
);
}
return FlowyResult.success(code);
},
(error) => FlowyResult.failure(error),
);
} catch (e) {

View File

@ -52,6 +52,11 @@ class WorkspaceMemberBloc
updateSubscriptionInfo: (info) async =>
_onUpdateSubscriptionInfo(emit, info),
upgradePlan: () async => _onUpgradePlan(),
updateInviteLink: (inviteLink) async => emit(
state.copyWith(
inviteLink: inviteLink,
),
),
);
});
}
@ -90,7 +95,7 @@ class WorkspaceMemberBloc
_memberHttpService?.getInviteCode(workspaceId: _workspaceId).fold(
(s) async {
final inviteLink = await _buildInviteLink(inviteCode: s);
emit(state.copyWith(inviteLink: inviteLink));
add(WorkspaceMemberEvent.updateInviteLink(inviteLink));
},
(e) => Log.info('Failed to get invite code: ${e.msg}', e),
),
@ -371,6 +376,9 @@ class WorkspaceMemberEvent with _$WorkspaceMemberEvent {
) = UpdateSubscriptionInfo;
const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan;
const factory WorkspaceMemberEvent.updateInviteLink(String inviteLink) =
UpdateInviteLink;
}
enum WorkspaceMemberActionType {

View File

@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/built_in_svgs.dart';
import 'package:appflowy/util/color_generator/color_generator.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@ -42,6 +43,7 @@ class UserAvatar extends StatelessWidget {
}
Widget _buildEmptyAvatar(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final String nameOrDefault = _userName(name);
final Color color = ColorGenerator(name).toColor();
const initialsCount = 2;
@ -69,10 +71,11 @@ class UserAvatar extends StatelessWidget {
)
: null,
),
child: FlowyText.medium(
child: Text(
nameInitials,
color: Colors.black,
fontSize: fontSize,
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.primary)
.copyWith(fontSize: fontSize),
),
);
}

View File

@ -144,28 +144,28 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3

View File

@ -15,8 +15,7 @@ class Log {
bool enableFlutterLog = true;
// used to disable log in tests
@visibleForTesting
bool disableLog = true;
bool disableLog = false;
Log() {
_logger = Talker(

View File

@ -25,7 +25,7 @@ class AFDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final color = this.color ?? theme.borderColorScheme.greyTertiary;
final color = this.color ?? theme.borderColorScheme.primary;
return switch (axis) {
Axis.horizontal => Container(

View File

@ -1354,7 +1354,7 @@
"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",
"resetInviteLinkDescription": "Resetting will deactivate the current link for all space members and generate a new one. The old link will no longer be available.",
"adminPanel": "Admin Panel",
"reset": "Reset",
"resetInviteLinkSuccess": "Invite link reset successfully",