From 6a887fdca9145ee3731c5996cceece371f387d34 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 24 Apr 2025 10:28:18 +0800 Subject: [PATCH] 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 --- .../setting/about/about_setting_group.dart | 21 +- .../setting/ai/ai_settings_group.dart | 20 +- .../setting/appearance/rtl_setting.dart | 14 +- .../appearance/text_scale_setting.dart | 14 +- .../setting/appearance/theme_setting.dart | 14 +- .../setting/cloud/cloud_setting_group.dart | 34 +- .../setting/font/font_setting.dart | 15 +- .../setting/language_setting_group.dart | 15 +- .../personal_info_setting_group.dart | 28 +- .../setting/support_setting_group.dart | 13 +- .../setting/user_session_setting_group.dart | 14 +- .../widgets/mobile_setting_group_widget.dart | 15 +- .../widgets/mobile_setting_item_widget.dart | 14 +- .../widgets/mobile_setting_trailing.dart | 42 +++ .../setting/workspace/add_members_screen.dart | 349 +++++++++++++++++ .../workspace/invite_member_by_link.dart | 356 ++++++++++++++++++ .../workspace/invite_members_screen.dart | 163 ++++---- .../setting/workspace/member_list.dart | 89 +++-- .../workspace/workspace_setting_group.dart | 14 +- .../mention/mention_page_bloc.dart | 4 +- .../appflowy_flutter/lib/startup/startup.dart | 3 + .../lib/startup/tasks/generate_router.dart | 12 + .../widgets/sign_in_or_logout_button.dart | 15 +- .../menu/sidebar/space/shared_widget.dart | 18 +- .../inivitation/m_invite_member_by_email.dart | 94 +++++ .../inivitation/m_invite_member_by_link.dart | 155 ++++++++ .../inivitation/member_http_service.dart | 10 +- .../members/workspace_member_bloc.dart | 10 +- .../presentation/widgets/user_avatar.dart | 9 +- frontend/appflowy_flutter/macos/Podfile.lock | 36 +- .../packages/appflowy_backend/lib/log.dart | 3 +- .../lib/src/component/separator/divider.dart | 2 +- frontend/resources/translations/en.json | 2 +- 33 files changed, 1305 insertions(+), 312 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_trailing.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/add_members_screen.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_member_by_link.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart index 2d5a3176cd..417ffb0e0a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart @@ -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, ), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart index b43ada6e42..fe0e0a7160 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart @@ -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), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart index 5b8035f004..0277a6ad70 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart @@ -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().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( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart index 7c89185e79..63552e3b6e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart @@ -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 { @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( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart index 8893eab105..1e665a5154 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart @@ -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().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( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart index 7410554632..f60e56178c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart @@ -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), - ), - ], - ), + ], + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart index 1076b9dba6..38d3e409d4 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart @@ -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().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(FontPickerScreen.routeName); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart index 6473485514..23c0c47a25 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart @@ -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 { 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 = diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index 28ebdb750e..f074ec685d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -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( create: (context) => getIt( 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, + ), + ), + ), ], ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart index e5e4efef77..54b1868112 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart @@ -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( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index 5ca5525099..05d0f1cbd2 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -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, ), ], ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart index 17e9a62867..5e68d9aea9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart @@ -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(), ], ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart index 82c86065ae..1b9a0c64c5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart @@ -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, ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_trailing.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_trailing.dart new file mode 100644 index 0000000000..561d2cc136 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_trailing.dart @@ -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, + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/add_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/add_members_screen.dart new file mode 100644 index 0000000000..e760e91779 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/add_members_screen.dart @@ -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 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( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocConsumer( + 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().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() + // .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 {}, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_member_by_link.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_member_by_link.dart new file mode 100644 index 0000000000..fad7fe3e39 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_member_by_link.dart @@ -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 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 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( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocConsumer( + 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() + .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; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart index 18bce0588b..40cd7ffe4f 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -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().setPlainText(inviteLink); + } + }, + (f) { + Log.error('generate invite link failed: $f'); + showToastNotification( + type: ToastificationType.error, + message: 'Failed to generate invite link', + ); + }, ); - return; } - context - .read() - .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, ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart index b2805d5857..1076898823 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart @@ -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, ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart index 9c2161a4d1..f7036d33d4 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart @@ -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() + .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); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart index 28a698dde2..af3c7cf437 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart @@ -119,7 +119,9 @@ class MentionPageBloc extends Bloc { 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)); + } } }, ); diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 1b835130e6..781632c69f 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -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().dispose(); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index e3886eafbc..e2d30bd967 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -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, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart index 7067844500..eefc21ab59 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart @@ -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), - ); - }, ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index 95130b029e..eb33659125 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -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 { @override Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); return KeyboardListener( focusNode: focusNode, autofocus: true, @@ -353,24 +355,28 @@ class _ConfirmPopupState extends State { } }, 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), ], ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart new file mode 100644 index 0000000000..5e1271db03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart @@ -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 createState() => _MInviteMemberByEmailState(); +} + +class _MInviteMemberByEmailState extends State { + 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() + .add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email)); + // clear the email field after inviting + _emailController.clear(); + } + + void _onEmailChanged() { + setState(() { + _isInviteButtonEnabled = _emailController.text.isNotEmpty; + }); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart new file mode 100644 index 0000000000..41f1363efe --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart @@ -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 _onGenerateInviteLink(BuildContext context) async { + final inviteLink = context.read().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().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().add( + const WorkspaceMemberEvent.generateInviteLink(), + ); + + Navigator.of(dialogContext).pop(); + }, + ), + ); + } else { + context.read().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().state.inviteLink; + if (link != null) { + getIt().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, + ); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/member_http_service.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/member_http_service.dart index 01d507ea24..3cfd67210b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/member_http_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/inivitation/member_http_service.dart @@ -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) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart index 3fc13c7b18..98f94cd54e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart @@ -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 { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart index 1c2d74eabc..aa03657f29 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart @@ -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), ), ); } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 9c949dd02f..7f2c82ad2f 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -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 diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart index 2b89b3c03d..84094a69bf 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -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( diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/separator/divider.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/separator/divider.dart index fa5dcd093d..892c92bcf5 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/separator/divider.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/separator/divider.dart @@ -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( diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index de5855bff0..91abe94cc5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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",