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
This commit is contained in:
Lucas 2024-11-18 10:33:51 +08:00 committed by GitHub
parent 8a39ff0580
commit 7e528cf260
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 363 additions and 91 deletions

View File

@ -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(),

View File

@ -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<ViewBloc, ViewState>(
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<ViewBloc>().add(const ViewEvent.duplicate());
context.pop();
break;
case MobileViewBottomSheetBodyAction.delete:
context.read<ViewBloc>().add(const ViewEvent.delete());
context.pop();
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
case MobileViewBottomSheetBodyAction.removeFromFavorites:
context.read<FavoriteBloc>().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<ShareBloc, ShareState>(
listener: (context, state) => _showToast(context, state),
child: BlocListener<ViewBloc, ViewState>(
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<void> _onAction(
BuildContext context,
MobileViewBottomSheetBodyAction action,
) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
_duplicate(context);
break;
case MobileViewBottomSheetBodyAction.delete:
context.read<ViewBloc>().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<void> _publish(BuildContext context) async {
final id = context.read<ShareBloc>().view.id;
final lastPublishName = context.read<ShareBloc>().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<ViewBloc>().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<FavoriteBloc>().add(FavoriteEvent.toggle(view));
context.pop();
}
void _unpublish(BuildContext context) {
context.read<ShareBloc>().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<ViewBloc>().add(ViewEvent.rename(name));
}
}
void _updatePathName(BuildContext context) async {
final shareBloc = context.read<ShareBloc>();
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<void, FlowyError>? previousUpdatePathNameResult;
return EditWorkspaceNameBottomSheet(
type: EditWorkspaceNameType.edit,
workspaceName: pathName,
hintText: '',
validator: (value) => null,
validatorBuilder: (context) {
return BlocProvider.value(
value: shareBloc,
child: BlocBuilder<ShareBloc, ShareState>(
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();
},
);
}
}
}

View File

@ -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<ShareBloc>().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,

View File

@ -363,6 +363,7 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
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');

View File

@ -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<EditWorkspaceNameBottomSheet> createState() =>
_EditWorkspaceNameBottomSheetState();
@ -71,6 +77,7 @@ class _EditWorkspaceNameBottomSheetState
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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,

View File

@ -74,6 +74,13 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
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

View File

@ -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,
],
],
),
),
);
}

View File

@ -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",