浏览代码

fix: the share markdown feature doesn't work in 0.2.0 (#2756)

* chore: bump version 0.2.0

* fix: the share markdown feature doesn't work in 0.2.0

* test: add integration test for share button
Lucas.Xu 1 年之前
父节点
当前提交
ed04e247ba

+ 75 - 0
frontend/appflowy_flutter/integration_test/share_markdown_test.dart

@@ -0,0 +1,75 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import 'util/mock/mock_file_picker.dart';
+import 'util/util.dart';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('share markdown in document page', () {
+    const location = 'markdown';
+
+    setUp(() async {
+      await TestFolder.cleanTestLocation(location);
+      await TestFolder.setTestLocation(location);
+    });
+
+    tearDown(() async {
+      await TestFolder.cleanTestLocation(location);
+    });
+
+    tearDownAll(() async {
+      await TestFolder.cleanTestLocation(null);
+    });
+
+    testWidgets('click the share button in document page', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // expect to see a readme page
+      tester.expectToSeePageName(readme);
+
+      // mock the file picker
+      final path = await mockSaveFilePath(location, 'test.md');
+      // click the share button and select markdown
+      await tester.tapShareButton();
+      await tester.tapMarkdownButton();
+
+      // expect to see the success dialog
+      tester.expectToExportSuccess();
+
+      final file = File(path);
+      final isExist = file.existsSync();
+      expect(isExist, true);
+      final markdown = file.readAsStringSync();
+      expect(markdown, expectedMarkdown);
+    });
+  });
+}
+
+const expectedMarkdown = r'''
+# Welcome to AppFlowy!
+## Here are the basics
+- [ ] Click anywhere and just start typing.
+- [ ] Highlight any text, and use the editing menu to _style_ **your** <u>writing</u> `however` you ~~like.~~
+- [ ] As soon as you type `/` a menu will pop up. Select different types of content blocks you can add.
+- [ ] Type `/` followed by `/bullet` or `/num` to create a list.
+- [x] Click `+ New Page `button at the bottom of your sidebar to add a new page.
+- [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`.
+
+---
+
+## Keyboard shortcuts, markdown, and code block
+1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts)
+1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown)
+1. Type `/code` to insert a code block
+
+## Have a question❓
+> Click `?` at the bottom right for help and support.
+
+
+
+''';

+ 25 - 0
frontend/appflowy_flutter/integration_test/util/launch.dart

@@ -2,11 +2,13 @@ import 'dart:ui';
 
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/plugins/document/presentation/banner.dart';
+import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
 import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
 import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/home/menu/app/header/add_button.dart';
 import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
 import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 import 'base.dart';
@@ -97,4 +99,27 @@ extension AppFlowyLaunch on WidgetTester {
     );
     await tapButton(restoreButton);
   }
+
+  Future<void> tapShareButton() async {
+    final shareButton = find.byWidgetPredicate(
+      (widget) => widget is DocumentShareButton,
+    );
+    await tapButton(shareButton);
+  }
+
+  Future<void> tapMarkdownButton() async {
+    final markdownButton = find.textContaining(
+      LocaleKeys.shareAction_markdown.tr(),
+    );
+    await tapButton(markdownButton);
+  }
+
+  void expectToExportSuccess() {
+    final exportSuccess = find.byWidgetPredicate(
+      (widget) =>
+          widget is FlowyText &&
+          widget.title == LocaleKeys.settings_files_exportFileSuccess.tr(),
+    );
+    expect(exportSuccess, findsOneWidget);
+  }
 }

+ 43 - 3
frontend/appflowy_flutter/integration_test/util/mock/mock_file_picker.dart

@@ -1,10 +1,10 @@
 import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/util/file_picker/file_picker_impl.dart';
 import 'package:appflowy/util/file_picker/file_picker_service.dart';
-
+import 'package:file_picker/src/file_picker.dart' as fp;
+import 'package:path/path.dart' as p;
 import '../util.dart';
 
-class MockFilePicker extends FilePicker {
+class MockFilePicker implements FilePickerService {
   MockFilePicker({
     required this.mockPath,
   });
@@ -15,6 +15,34 @@ class MockFilePicker extends FilePicker {
   Future<String?> getDirectoryPath({String? title}) {
     return Future.value(mockPath);
   }
+
+  @override
+  Future<String?> saveFile({
+    String? dialogTitle,
+    String? fileName,
+    String? initialDirectory,
+    fp.FileType type = fp.FileType.any,
+    List<String>? allowedExtensions,
+    bool lockParentWindow = false,
+  }) {
+    return Future.value(mockPath);
+  }
+
+  @override
+  Future<FilePickerResult?> pickFiles({
+    String? dialogTitle,
+    String? initialDirectory,
+    fp.FileType type = fp.FileType.any,
+    List<String>? allowedExtensions,
+    Function(fp.FilePickerStatus p1)? onFileLoading,
+    bool allowCompression = true,
+    bool allowMultiple = false,
+    bool withData = false,
+    bool withReadStream = false,
+    bool lockParentWindow = false,
+  }) {
+    throw UnimplementedError();
+  }
 }
 
 Future<void> mockGetDirectoryPath(String? name) async {
@@ -27,3 +55,15 @@ Future<void> mockGetDirectoryPath(String? name) async {
   );
   return;
 }
+
+Future<String> mockSaveFilePath(String? name, String fileName) async {
+  final dir = await TestFolder.testLocation(name);
+  final path = p.join(dir.path, fileName);
+  getIt.unregister<FilePickerService>();
+  getIt.registerFactory<FilePickerService>(
+    () => MockFilePicker(
+      mockPath: path,
+    ),
+  );
+  return path;
+}

+ 0 - 1
frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart

@@ -1,4 +1,3 @@
 export 'doc_bloc.dart';
 export 'doc_service.dart';
 export 'share_bloc.dart';
-export 'share_service.dart';

+ 28 - 47
frontend/appflowy_flutter/lib/plugins/document/application/share_bloc.dart

@@ -1,65 +1,46 @@
-import 'dart:convert';
 import 'dart:io';
-import 'package:appflowy/plugins/document/application/share_service.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/divider_node_parser.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/code_block_node_parser.dart';
+import 'package:appflowy/workspace/application/export/document_exporter.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:dartz/dartz.dart';
-import 'package:appflowy_editor/appflowy_editor.dart'
-    show Document, documentToMarkdown;
 part 'share_bloc.freezed.dart';
 
 class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
-  ShareService service;
-  ViewPB view;
-  DocShareBloc({required this.view, required this.service})
-      : super(const DocShareState.initial()) {
-    on<DocShareEvent>((event, emit) async {
-      await event.map(
-        shareMarkdown: (ShareMarkdown shareMarkdown) async {
-          await service.exportMarkdown(view).then((result) {
-            result.fold(
-              (value) => emit(
-                DocShareState.finish(
-                  left(_saveMarkdown(value, shareMarkdown.path)),
-                ),
-              ),
-              (error) => emit(DocShareState.finish(right(error))),
-            );
-          });
-
-          emit(const DocShareState.loading());
-        },
-        shareLink: (ShareLink value) {},
-        shareText: (ShareText value) {},
-      );
-    });
+  DocShareBloc({
+    required this.view,
+  }) : super(const DocShareState.initial()) {
+    on<ShareMarkdown>(_onShareMarkdown);
   }
 
-  ExportDataPB _saveMarkdown(ExportDataPB value, String path) {
-    final markdown = _convertDocumentToMarkdown(value);
-    value.data = markdown;
-    File(path).writeAsStringSync(markdown);
-    return value;
-  }
+  final ViewPB view;
+
+  Future<void> _onShareMarkdown(
+    ShareMarkdown event,
+    Emitter<DocShareState> emit,
+  ) async {
+    emit(const DocShareState.loading());
 
-  String _convertDocumentToMarkdown(ExportDataPB value) {
-    final json = jsonDecode(value.data);
-    final document = Document.fromJson(json);
-    return documentToMarkdown(
-      document,
-      customParsers: [
-        const DividerNodeParser(),
-        const MathEquationNodeParser(),
-        const CodeBlockNodeParser(),
-      ],
+    final documentExporter = DocumentExporter(view);
+    final result = await documentExporter.export(DocumentExportType.markdown);
+    emit(
+      DocShareState.finish(
+        result.fold(
+          (error) => right(error),
+          (markdown) => left(_saveMarkdownToPath(markdown, event.path)),
+        ),
+      ),
     );
   }
+
+  ExportDataPB _saveMarkdownToPath(String markdown, String path) {
+    File(path).writeAsStringSync(markdown);
+    return ExportDataPB()
+      ..data = markdown
+      ..exportType = ExportType.Markdown;
+  }
 }
 
 @freezed

+ 0 - 32
frontend/appflowy_flutter/lib/plugins/document/application/share_service.dart

@@ -1,32 +0,0 @@
-import 'dart:async';
-import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
-import 'package:dartz/dartz.dart';
-import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
-
-class ShareService {
-  Future<Either<ExportDataPB, FlowyError>> export(
-    ViewPB view,
-    ExportType type,
-  ) {
-    // var payload = ExportPayloadPB.create()
-    //   ..viewId = view.id
-    //   ..exportType = type
-    //   ..documentVersion = DocumentVersionPB.V1;
-
-    // return DocumentEventExportDocument(payload).send();
-    throw UnimplementedError();
-  }
-
-  Future<Either<ExportDataPB, FlowyError>> exportText(ViewPB view) {
-    return export(view, ExportType.Text);
-  }
-
-  Future<Either<ExportDataPB, FlowyError>> exportMarkdown(ViewPB view) {
-    return export(view, ExportType.Markdown);
-  }
-
-  Future<Either<ExportDataPB, FlowyError>> exportURL(ViewPB view) {
-    return export(view, ExportType.Link);
-  }
-}

+ 20 - 29
frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart

@@ -1,26 +1,25 @@
-library document_plugin;
-
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/plugins/document/application/share_bloc.dart';
+import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/presentation/home/toast.dart';
 import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
-import 'package:clipboard/clipboard.dart';
 import 'package:easy_localization/easy_localization.dart';
-import 'package:file_picker/file_picker.dart';
 import 'package:flowy_infra_ui/widget/rounded_button.dart';
-import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
 class DocumentShareButton extends StatelessWidget {
+  const DocumentShareButton({
+    super.key,
+    required this.view,
+  });
+
   final ViewPB view;
-  DocumentShareButton({Key? key, required this.view})
-      : super(key: ValueKey(view.hashCode));
 
   @override
   Widget build(BuildContext context) {
@@ -28,12 +27,10 @@ class DocumentShareButton extends StatelessWidget {
       create: (context) => getIt<DocShareBloc>(param1: view),
       child: BlocListener<DocShareBloc, DocShareState>(
         listener: (context, state) {
-          state.map(
-            initial: (_) {},
-            loading: (_) {},
+          state.mapOrNull(
             finish: (state) {
               state.successOrFail.fold(
-                _handleExportData,
+                (data) => _handleExportData(context, data),
                 _handleExportError,
               );
             },
@@ -52,27 +49,30 @@ class DocumentShareButton extends StatelessWidget {
     );
   }
 
-  void _handleExportData(ExportDataPB exportData) {
+  void _handleExportData(BuildContext context, ExportDataPB exportData) {
     switch (exportData.exportType) {
-      case ExportType.Link:
-        break;
       case ExportType.Markdown:
-        FlutterClipboard.copy(exportData.data)
-            .then((value) => Log.info('copied to clipboard'));
+        showSnackBarMessage(
+          context,
+          LocaleKeys.settings_files_exportFileSuccess.tr(),
+        );
         break;
+      case ExportType.Link:
       case ExportType.Text:
         break;
     }
   }
 
-  void _handleExportError(FlowyError error) {}
+  void _handleExportError(FlowyError error) {
+    showMessageToast(error.msg);
+  }
 }
 
 class ShareActionList extends StatelessWidget {
   const ShareActionList({
-    Key? key,
+    super.key,
     required this.view,
-  }) : super(key: key);
+  });
 
   final ViewPB view;
 
@@ -94,20 +94,14 @@ class ShareActionList extends StatelessWidget {
       onSelected: (action, controller) async {
         switch (action.inner) {
           case ShareAction.markdown:
-            final exportPath = await FilePicker.platform.saveFile(
+            final exportPath = await getIt<FilePickerService>().saveFile(
               dialogTitle: '',
               fileName: '${view.name}.md',
             );
             if (exportPath != null) {
               docShareBloc.add(DocShareEvent.shareMarkdown(exportPath));
-              showMessageToast('Exported to: $exportPath');
             }
             break;
-          // case ShareAction.copyLink:
-          //   NavigatorAlertDialog(
-          //     title: LocaleKeys.shareAction_workInProgress.tr(),
-          //   ).show(context);
-          //   break;
         }
         controller.close();
       },
@@ -117,7 +111,6 @@ class ShareActionList extends StatelessWidget {
 
 enum ShareAction {
   markdown,
-  // copyLink,
 }
 
 class ShareActionWrapper extends ActionCell {
@@ -132,8 +125,6 @@ class ShareActionWrapper extends ActionCell {
     switch (inner) {
       case ShareAction.markdown:
         return LocaleKeys.shareAction_markdown.tr();
-      // case ShareAction.copyLink:
-      //   return LocaleKeys.shareAction_copyLink.tr();
     }
   }
 }

+ 1 - 2
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -110,9 +110,8 @@ void _resolveHomeDeps(GetIt getIt) {
   );
 
   // share
-  getIt.registerLazySingleton<ShareService>(() => ShareService());
   getIt.registerFactoryParam<DocShareBloc, ViewPB, void>(
-    (view, _) => DocShareBloc(view: view, service: getIt<ShareService>()),
+    (view, _) => DocShareBloc(view: view),
   );
 }
 

+ 19 - 0
frontend/appflowy_flutter/lib/util/file_picker/file_picker_impl.dart

@@ -34,4 +34,23 @@ class FilePicker implements FilePickerService {
     );
     return FilePickerResult(result?.files ?? []);
   }
+
+  @override
+  Future<String?> saveFile({
+    String? dialogTitle,
+    String? fileName,
+    String? initialDirectory,
+    fp.FileType type = fp.FileType.any,
+    List<String>? allowedExtensions,
+    bool lockParentWindow = false,
+  }) {
+    return fp.FilePicker.platform.saveFile(
+      dialogTitle: dialogTitle,
+      fileName: fileName,
+      initialDirectory: initialDirectory,
+      type: type,
+      allowedExtensions: allowedExtensions,
+      lockParentWindow: lockParentWindow,
+    );
+  }
 }

+ 10 - 0
frontend/appflowy_flutter/lib/util/file_picker/file_picker_service.dart

@@ -27,4 +27,14 @@ abstract class FilePickerService {
     bool lockParentWindow = false,
   }) async =>
       throw UnimplementedError('pickFiles() has not been implemented.');
+
+  Future<String?> saveFile({
+    String? dialogTitle,
+    String? fileName,
+    String? initialDirectory,
+    FileType type = FileType.any,
+    List<String>? allowedExtensions,
+    bool lockParentWindow = false,
+  }) async =>
+      throw UnimplementedError('saveFile() has not been implemented.');
 }

+ 58 - 0
frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart

@@ -0,0 +1,58 @@
+import 'dart:convert';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
+import 'package:appflowy/plugins/document/application/prelude.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/code_block_node_parser.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/divider_node_parser.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:dartz/dartz.dart';
+import 'package:easy_localization/easy_localization.dart';
+
+enum DocumentExportType {
+  json,
+  markdown,
+  text,
+}
+
+class DocumentExporter {
+  const DocumentExporter(
+    this.view,
+  );
+
+  final ViewPB view;
+
+  Future<Either<FlowyError, String>> export(DocumentExportType type) async {
+    final documentService = DocumentService();
+    final result = await documentService.openDocument(view: view);
+    return result.fold((error) => left(error), (r) {
+      final document = r.toDocument();
+      if (document == null) {
+        return left(
+          FlowyError(
+            msg: LocaleKeys.settings_files_exportFileFail.tr(),
+          ),
+        );
+      }
+      switch (type) {
+        case DocumentExportType.json:
+          return right(jsonEncode(document));
+        case DocumentExportType.markdown:
+          final markdown = documentToMarkdown(
+            document,
+            customParsers: [
+              const DividerNodeParser(),
+              const MathEquationNodeParser(),
+              const CodeBlockNodeParser(),
+            ],
+          );
+          return right(markdown);
+        case DocumentExportType.text:
+          throw UnimplementedError();
+      }
+    });
+  }
+}

+ 11 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart

@@ -39,3 +39,14 @@ void showMessageToast(String message) {
     toastDuration: const Duration(seconds: 3),
   );
 }
+
+void showSnackBarMessage(BuildContext context, String message) {
+  ScaffoldMessenger.of(context).showSnackBar(
+    SnackBar(
+      content: FlowyText(
+        message,
+        color: Theme.of(context).colorScheme.onSurface,
+      ),
+    ),
+  );
+}

+ 7 - 10
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart

@@ -1,10 +1,8 @@
-import 'dart:convert';
 import 'dart:io';
 
-import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
-import 'package:appflowy/plugins/document/application/prelude.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/util/file_picker/file_picker_service.dart';
+import 'package:appflowy/workspace/application/export/document_exporter.dart';
 import 'package:appflowy/workspace/application/settings/settings_file_exporter_cubit.dart';
 import 'package:appflowy/workspace/application/settings/share/export_service.dart';
 import 'package:appflowy_backend/log.dart';
@@ -248,18 +246,17 @@ class _AppFlowyFileExporter {
   ) async {
     final failedFileNames = <String>[];
     final Map<String, int> names = {};
-    final documentService = DocumentService();
     for (final view in views) {
       String? content;
       String? fileExtension;
       switch (view.layout) {
         case ViewLayoutPB.Document:
-          final document = await documentService.openDocument(view: view);
-          document.fold((l) => Log.error(l), (r) {
-            final json = r.toDocument()?.toJson();
-            if (json != null) {
-              content = jsonEncode(json);
-            }
+          final documentExporter = DocumentExporter(view);
+          final result = await documentExporter.export(
+            DocumentExportType.json,
+          );
+          result.fold((l) => Log.error(l), (json) {
+            content = json;
           });
           fileExtension = 'afdocument';
           break;

+ 1 - 1
frontend/scripts/makefile/flutter.toml

@@ -169,7 +169,7 @@ script = ["""
   cd appflowy_flutter/
   flutter clean
   flutter pub get
-  flutter build ${TARGET_OS} --${BUILD_FLAG}
+  flutter build ${TARGET_OS} --${BUILD_FLAG} --verbose
   """]
 script_runner = "@shell"