From 7e528cf2606f3021a17b2ceb8e819d843cb8132e Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 18 Nov 2024 10:33:51 +0800 Subject: [PATCH] feat: support editing path name on mobile (#6798) * feat: support editing path name on mobile * chore: format code * chore: update publish toast * feat: optimize the toast for mobile more actions menu * feat: optimize update path name logic * test: add update path name test * fix: integration test --- .../mobile/cloud/document/publish_test.dart | 53 +++- .../base/view_page/more_bottom_sheet.dart | 294 +++++++++++++----- .../bottom_sheet/bottom_sheet_view_page.dart | 10 + .../home/space/mobile_space_menu.dart | 1 + .../workspaces/create_workspace_menu.dart | 25 +- .../lib/plugins/shared/share/share_bloc.dart | 8 + .../presentation/widgets/dialogs.dart | 60 +++- frontend/resources/translations/en.json | 3 +- 8 files changed, 363 insertions(+), 91 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart b/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart index a3fcfe128c..e6015d0896 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart @@ -1,6 +1,8 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -11,7 +13,11 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('publish:', () { - testWidgets('publish document', (tester) async { + testWidgets(''' +1. publish document +2. update path name +3. unpublish document +''', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); @@ -45,6 +51,51 @@ void main() { findsOneWidget, ); + // update the path name + await tester.editor.clickMoreActionItemOnMobile( + LocaleKeys.shareAction_updatePathName.tr(), + ); + + const pathName1 = '???????????????'; + const pathName2 = 'AppFlowy'; + + final textField = find.descendant( + of: find.byType(EditWorkspaceNameBottomSheet), + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, pathName1); + await tester.pumpAndSettle(); + + // wait 50ms to ensure the error message is shown + await tester.wait(50); + + // click the confirm button + final confirmButton = find.text(LocaleKeys.button_confirm.tr()); + await tester.tapButton(confirmButton); + + // expect to see the update path name failed toast + final updatePathFailedText = find.text( + LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters + .tr(), + ); + expect(updatePathFailedText, findsOneWidget); + + // input the valid path name + await tester.enterText(textField, pathName2); + await tester.pumpAndSettle(); + // click the confirm button + await tester.tapButton(confirmButton); + + // wait 50ms to ensure the error message is shown + await tester.wait(50); + + // expect to see the update path name success toast + final updatePathSuccessText = find.findTextInFlowyText( + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + expect(updatePathSuccessText, findsOneWidget); + await tester.pumpUntilNotFound(updatePathSuccessText); + // unpublish the document await tester.editor.clickMoreActionItemOnMobile( LocaleKeys.shareAction_unPublish.tr(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index ce5ce33d80..eba8b09025 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -3,18 +3,24 @@ import 'dart:async'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/shared/share/publish_name_generator.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/shared/error_code/error_code_map.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.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'; @@ -24,78 +30,89 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { const MobileViewPageMoreBottomSheet({super.key, required this.view}); final ViewPB view; + @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state.successOrFailure.isSuccess && state.isDeleted) { - context.go('/home'); - } - }, - child: ViewPageBottomSheet( - view: view, - onAction: (action) async { - switch (action) { - case MobileViewBottomSheetBodyAction.duplicate: - context.read().add(const ViewEvent.duplicate()); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.delete: - context.read().add(const ViewEvent.delete()); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.addToFavorites: - case MobileViewBottomSheetBodyAction.removeFromFavorites: - context.read().add(FavoriteEvent.toggle(view)); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.undo: - EditorNotification.undo().post(); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.redo: - EditorNotification.redo().post(); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.helpCenter: - // unimplemented - context.pop(); - break; - case MobileViewBottomSheetBodyAction.publish: - await _publish(context); - if (context.mounted) { - context.pop(); - } - break; - case MobileViewBottomSheetBodyAction.unpublish: - _unpublish(context); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.copyPublishLink: - _copyPublishLink(context); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.visitSite: - _visitPublishedSite(context); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.copyShareLink: - _copyShareLink(context); - context.pop(); - break; - case MobileViewBottomSheetBodyAction.rename: - // no need to implement, rename is handled by the onRename callback. - throw UnimplementedError(); + return BlocListener( + listener: (context, state) => _showToast(context, state), + child: BlocListener( + listener: (context, state) { + if (state.successOrFailure.isSuccess && state.isDeleted) { + context.go('/home'); } }, - onRename: (name) { - _onRename(context, name); - context.pop(); - }, + child: ViewPageBottomSheet( + view: view, + onAction: (action) async => _onAction(context, action), + onRename: (name) { + _onRename(context, name); + context.pop(); + }, + ), ), ); } + Future _onAction( + BuildContext context, + MobileViewBottomSheetBodyAction action, + ) async { + switch (action) { + case MobileViewBottomSheetBodyAction.duplicate: + _duplicate(context); + break; + case MobileViewBottomSheetBodyAction.delete: + context.read().add(const ViewEvent.delete()); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.addToFavorites: + _addFavorite(context); + break; + case MobileViewBottomSheetBodyAction.removeFromFavorites: + _removeFavorite(context); + break; + case MobileViewBottomSheetBodyAction.undo: + EditorNotification.undo().post(); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.redo: + EditorNotification.redo().post(); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.helpCenter: + // unimplemented + context.pop(); + break; + case MobileViewBottomSheetBodyAction.publish: + await _publish(context); + if (context.mounted) { + context.pop(); + } + break; + case MobileViewBottomSheetBodyAction.unpublish: + _unpublish(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.copyPublishLink: + _copyPublishLink(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.visitSite: + _visitPublishedSite(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.copyShareLink: + _copyShareLink(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.updatePathName: + _updatePathName(context); + case MobileViewBottomSheetBodyAction.rename: + // no need to implement, rename is handled by the onRename callback. + throw UnimplementedError(); + } + } + Future _publish(BuildContext context) async { final id = context.read().view.id; final lastPublishName = context.read().state.pathName; @@ -113,19 +130,44 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { [view.id], ), ); - showToastNotification( - context, - message: LocaleKeys.publish_publishSuccessfully.tr(), - ); } } + void _duplicate(BuildContext context) { + context.read().add(const ViewEvent.duplicate()); + context.pop(); + + showToastNotification( + context, + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); + } + + void _addFavorite(BuildContext context) { + _toggleFavorite(context); + + showToastNotification( + context, + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + } + + void _removeFavorite(BuildContext context) { + _toggleFavorite(context); + + showToastNotification( + context, + message: LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + } + + void _toggleFavorite(BuildContext context) { + context.read().add(FavoriteEvent.toggle(view)); + context.pop(); + } + void _unpublish(BuildContext context) { context.read().add(const ShareEvent.unPublish()); - showToastNotification( - context, - message: LocaleKeys.publish_unpublishSuccessfully.tr(), - ); } void _copyPublishLink(BuildContext context) { @@ -186,4 +228,112 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { context.read().add(ViewEvent.rename(name)); } } + + void _updatePathName(BuildContext context) async { + final shareBloc = context.read(); + final pathName = shareBloc.state.pathName; + await showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.shareAction_updatePathName.tr(), + showCloseButton: true, + showDragHandle: true, + showDivider: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) { + FlowyResult? previousUpdatePathNameResult; + return EditWorkspaceNameBottomSheet( + type: EditWorkspaceNameType.edit, + workspaceName: pathName, + hintText: '', + validator: (value) => null, + validatorBuilder: (context) { + return BlocProvider.value( + value: shareBloc, + child: BlocBuilder( + builder: (context, state) { + final updatePathNameResult = state.updatePathNameResult; + + if (updatePathNameResult == null && + previousUpdatePathNameResult == null) { + return const SizedBox.shrink(); + } + + if (updatePathNameResult != null) { + previousUpdatePathNameResult = updatePathNameResult; + } + + final widget = previousUpdatePathNameResult?.fold( + (value) => const SizedBox.shrink(), + (error) => FlowyText( + error.code.publishErrorMessage.orDefault( + LocaleKeys.settings_sites_error_updatePathNameFailed + .tr(), + ), + maxLines: 3, + fontSize: 12, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).colorScheme.error, + ), + ) ?? + const SizedBox.shrink(); + + return widget; + }, + ), + ); + }, + onSubmitted: (name) { + // rename the path name + Log.info('rename the path name, from: $pathName, to: $name'); + + shareBloc.add(ShareEvent.updatePathName(name)); + }, + ); + }, + ); + shareBloc.add(const ShareEvent.clearPathNameResult()); + } + + void _showToast(BuildContext context, ShareState state) { + if (state.publishResult != null) { + state.publishResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.publish_publishSuccessfully.tr(), + ), + (error) => showToastNotification( + context, + message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', + type: ToastificationType.error, + ), + ); + } else if (state.unpublishResult != null) { + state.unpublishResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ), + (error) => showToastNotification( + context, + message: LocaleKeys.publish_unpublishFailed.tr(), + description: error.msg, + type: ToastificationType.error, + ), + ); + } else if (state.updatePathNameResult != null) { + state.updatePathNameResult!.onSuccess( + (value) { + showToastNotification( + context, + message: + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + + context.pop(); + }, + ); + } + } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 26f76164dc..a7c6fd10e3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -24,6 +24,7 @@ enum MobileViewBottomSheetBodyAction { copyPublishLink, visitSite, copyShareLink, + updatePathName, } typedef MobileViewBottomSheetBodyActionCallback = void Function( @@ -164,6 +165,15 @@ class MobileViewBottomSheetBody extends StatelessWidget { final isPublished = context.watch().state.isPublished; if (isPublished) { return [ + MobileQuickActionButton( + text: LocaleKeys.shareAction_updatePathName.tr(), + icon: FlowySvgs.view_item_rename_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.updatePathName, + ), + ), + _divider(), MobileQuickActionButton( text: LocaleKeys.shareAction_visitSite.tr(), icon: FlowySvgs.m_visit_site_s, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart index b1a4e4694b..0197f34940 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -363,6 +363,7 @@ class _SpaceMenuItemTrailingState extends State { type: EditWorkspaceNameType.edit, workspaceName: widget.space.name, hintText: LocaleKeys.space_spaceNamePlaceholder.tr(), + validator: (value) => null, onSubmitted: (name) { // rename the workspace Log.info('rename the space, from: ${widget.space.name}, to: $name'); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart index 155af7d4ff..741cbd6fe9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart @@ -33,6 +33,8 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget { required this.onSubmitted, required this.workspaceName, this.hintText, + this.validator, + this.validatorBuilder, }); final EditWorkspaceNameType type; @@ -43,6 +45,10 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget { final String? hintText; + final String? Function(String?)? validator; + + final WidgetBuilder? validatorBuilder; + @override State createState() => _EditWorkspaceNameBottomSheetState(); @@ -71,6 +77,7 @@ class _EditWorkspaceNameBottomSheetState @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Form( @@ -83,15 +90,21 @@ class _EditWorkspaceNameBottomSheetState hintText: widget.hintText ?? LocaleKeys.workspace_defaultName.tr(), ), - validator: (value) { - if (value == null || value.isEmpty) { - return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr(); - } - return null; - }, + validator: widget.validator ?? + (value) { + if (value == null || value.isEmpty) { + return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr(); + } + return null; + }, onEditingComplete: _onSubmit, ), ), + if (widget.validatorBuilder != null) ...[ + const VSpace(4), + widget.validatorBuilder!(context), + const VSpace(4), + ], const VSpace(16), SizedBox( width: double.infinity, diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart index 3a0b552eec..c533f67ed8 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -74,6 +74,13 @@ class ShareBloc extends Bloc { pathName, emit, ), + clearPathNameResult: () async { + emit( + state.copyWith( + updatePathNameResult: null, + ), + ); + }, ); }); } @@ -381,6 +388,7 @@ class ShareEvent with _$ShareEvent { const factory ShareEvent.setPublishStatus(bool isPublished) = _SetPublishStatus; const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName; + const factory ShareEvent.clearPathNameResult() = _ClearPathNameResult; } @freezed diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 541b92a497..7686b1d891 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -379,6 +379,7 @@ void showToastNotification( message: message, type: type, bottomPadding: bottomPadding, + description: description, ), ); return; @@ -416,11 +417,13 @@ class _MToast extends StatelessWidget { required this.message, this.type = ToastificationType.success, this.bottomPadding = 100, + this.description, }); final String message; final ToastificationType type; final double bottomPadding; + final String? description; @override Widget build(BuildContext context) { @@ -431,30 +434,65 @@ class _MToast extends StatelessWidget { color: Colors.white, maxLines: 10, ); + final descriptionText = description != null + ? FlowyText.regular( + description!, + fontSize: 12, + color: Colors.white, + maxLines: 10, + ) + : null; return Container( alignment: Alignment.bottomCenter, - padding: EdgeInsets.only(bottom: bottomPadding, left: 16, right: 16), + padding: EdgeInsets.only( + bottom: bottomPadding, + left: 16, + right: 16, + ), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 13.0, + ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.0), color: const Color(0xE5171717), ), child: type == ToastificationType.success - ? Row( + ? Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (type == ToastificationType.success) ...[ - const FlowySvg( - FlowySvgs.success_s, - blendMode: null, - ), - const HSpace(8.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (type == ToastificationType.success) ...[ + const FlowySvg( + FlowySvgs.success_s, + blendMode: null, + ), + const HSpace(8.0), + ], + Expanded(child: hintText), + ], + ), + if (descriptionText != null) ...[ + const VSpace(4.0), + descriptionText, ], - Expanded(child: hintText), ], ) - : hintText, + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + hintText, + if (descriptionText != null) ...[ + const VSpace(4.0), + descriptionText, + ], + ], + ), ), ); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 380c2d50a0..7f62a25bda 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -131,7 +131,8 @@ "copyLinkFailed": "Failed to copy link to clipboard", "copyLinkToBlockSuccess": "Copied block link to clipboard", "copyLinkToBlockFailed": "Failed to copy block link to clipboard", - "manageAllSites": "Manage all sites" + "manageAllSites": "Manage all sites", + "updatePathName": "Update path name" }, "moreAction": { "small": "small",