diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 7aa1f4f997..7ae081ba8a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -130,7 +130,7 @@ class DocumentBloc extends Bloc { final result = await UserBackendService.getCurrentUserProfile().then( (value) async => value.andThen( // open the document - await _documentService.openDocument(view: view), + await _documentService.openDocument(viewId: view.id), ), ); return state.copyWith( diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart index cdc01b7a2e..de13fb5ce4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart @@ -9,7 +9,7 @@ class DocumentService { Future> createDocument({ required ViewPB view, }) async { - final canOpen = await openDocument(view: view); + final canOpen = await openDocument(viewId: view.id); if (canOpen.isRight()) { return const Right(unit); } @@ -19,13 +19,30 @@ class DocumentService { } Future> openDocument({ - required ViewPB view, + required String viewId, }) async { - final payload = OpenDocumentPayloadPB()..documentId = view.id; + final payload = OpenDocumentPayloadPB()..documentId = viewId; final result = await DocumentEventOpenDocument(payload).send(); return result.swap(); } + Future> getBlockFromDocument({ + required DocumentDataPB document, + required String blockId, + }) async { + final block = document.blocks[blockId]; + + if (block != null) { + return right(block); + } + + return left( + FlowyError( + msg: 'Block($blockId) not found in Document(${document.pageId})', + ), + ); + } + Future> closeDocument({ required ViewPB view, }) async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 3c4299b6df..1c424a2c83 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -10,6 +10,8 @@ import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/export_page_widget.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/base64_string.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart' @@ -60,39 +62,45 @@ class _DocumentPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: documentBloc, - child: BlocBuilder( - builder: (context, state) { - return state.loadingState.when( - loading: () => - const Center(child: CircularProgressIndicator.adaptive()), - finish: (result) => result.fold( - (error) { - Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ); - }, - (data) { - if (state.forceClose) { - widget.onDeleted(); - return const SizedBox.shrink(); - } else if (documentBloc.editorState == null) { - return Center( - child: ExportPageWidget( - onTap: () async => await _exportPage(data), - ), + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: documentBloc), + ], + child: BlocListener( + listener: _onNotificationAction, + child: BlocBuilder( + builder: (context, state) { + return state.loadingState.when( + loading: () => + const Center(child: CircularProgressIndicator.adaptive()), + finish: (result) => result.fold( + (error) { + Log.error(error); + return FlowyErrorPage.message( + error.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), ); - } else { - editorState = documentBloc.editorState!; - return _buildEditorPage(context, state); - } - }, - ), - ); - }, + }, + (data) { + if (state.forceClose) { + widget.onDeleted(); + return const SizedBox.shrink(); + } else if (documentBloc.editorState == null) { + return Center( + child: ExportPageWidget( + onTap: () async => await _exportPage(data), + ), + ); + } else { + editorState = documentBloc.editorState!; + return _buildEditorPage(context, state); + } + }, + ), + ); + }, + ), ), ); } @@ -112,9 +120,7 @@ class _DocumentPageState extends State { return Column( children: [ if (state.isDeleted) _buildBanner(context), - Expanded( - child: appflowyEditorPage, - ), + Expanded(child: appflowyEditorPage), ], ); } @@ -151,4 +157,22 @@ class _DocumentPageState extends State { showSnackBarMessage(context, 'Export success to $path'); } } + + Future _onNotificationAction( + BuildContext context, + NotificationActionState state, + ) async { + if (state.action != null && state.action!.type == ActionType.jumpToBlock) { + final path = state.action?.arguments?[ActionArgumentKeys.nodePath.name]; + + if (editorState != null && widget.view.id == state.action?.objectId) { + editorState!.updateSelectionWithReason( + Selection.collapsed( + Position(path: [path]), + ), + reason: SelectionUpdateReason.transaction, + ); + } + } + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart new file mode 100644 index 0000000000..5d0394291e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -0,0 +1,231 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +Map getEditorBuilderMap({ + required BuildContext context, + required EditorState editorState, + required EditorStyleCustomizer styleCustomizer, + List? slashMenuItems, + bool editable = true, +}) { + final standardActions = [ + OptionAction.delete, + OptionAction.duplicate, + // OptionAction.divider, + // OptionAction.moveUp, + // OptionAction.moveDown, + ]; + + final calloutBGColor = AFThemeExtension.of(context).calloutBGColor; + + final configuration = BlockComponentConfiguration( + padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), + ); + + final customBlockComponentBuilderMap = { + PageBlockKeys.type: PageBlockComponentBuilder(), + ParagraphBlockKeys.type: ParagraphBlockComponentBuilder( + configuration: configuration, + ), + TodoListBlockKeys.type: TodoListBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => 'To-do', + ), + ), + BulletedListBlockKeys.type: BulletedListBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => 'List', + ), + ), + NumberedListBlockKeys.type: NumberedListBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => 'List', + ), + ), + QuoteBlockKeys.type: QuoteBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => 'Quote', + ), + ), + HeadingBlockKeys.type: HeadingBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0), + placeholderText: (node) => + 'Heading ${node.attributes[HeadingBlockKeys.level]}', + ), + textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level), + ), + ImageBlockKeys.type: CustomImageBlockComponentBuilder( + configuration: configuration, + showMenu: true, + menuBuilder: (Node node, CustomImageBlockComponentState state) => + Positioned( + top: 0, + right: 10, + child: ImageMenu( + node: node, + state: state, + ), + ), + ), + TableBlockKeys.type: TableBlockComponentBuilder( + menuBuilder: (node, editorState, position, dir, onBuild, onClose) => + TableMenu( + node: node, + editorState: editorState, + position: position, + dir: dir, + onBuild: onBuild, + onClose: onClose, + ), + ), + TableCellBlockKeys.type: TableCellBlockComponentBuilder( + menuBuilder: (node, editorState, position, dir, onBuild, onClose) => + TableMenu( + node: node, + editorState: editorState, + position: position, + dir: dir, + onBuild: onBuild, + onClose: onClose, + ), + ), + DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 10), + ), + ), + DatabaseBlockKeys.boardType: DatabaseViewBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 10), + ), + ), + DatabaseBlockKeys.calendarType: DatabaseViewBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 10), + ), + ), + CalloutBlockKeys.type: CalloutBlockComponentBuilder( + configuration: configuration, + defaultColor: calloutBGColor, + ), + DividerBlockKeys.type: DividerBlockComponentBuilder( + configuration: configuration, + height: 28.0, + ), + MathEquationBlockKeys.type: MathEquationBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 20), + ), + ), + CodeBlockKeys.type: CodeBlockComponentBuilder( + configuration: configuration.copyWith( + textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), + placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), + ), + padding: const EdgeInsets.only( + left: 30, + right: 30, + bottom: 36, + ), + ), + AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(), + SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(), + ToggleListBlockKeys.type: ToggleListBlockComponentBuilder( + configuration: configuration, + ), + OutlineBlockKeys.type: OutlineBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderTextStyle: (_) => + styleCustomizer.outlineBlockPlaceholderStyleBuilder(), + ), + ), + errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 10), + ), + ), + }; + + final builders = { + ...standardBlockComponentBuilderMap, + ...customBlockComponentBuilderMap, + }; + + if (editable) { + // customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience. + for (final entry in builders.entries) { + if (entry.key == PageBlockKeys.type) { + continue; + } + final builder = entry.value; + + // customize the action builder. + final supportColorBuilderTypes = [ + ParagraphBlockKeys.type, + HeadingBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + QuoteBlockKeys.type, + TodoListBlockKeys.type, + CalloutBlockKeys.type, + OutlineBlockKeys.type, + ToggleListBlockKeys.type, + ]; + + final supportAlignBuilderType = [ + ImageBlockKeys.type, + ]; + + final colorAction = [ + OptionAction.divider, + OptionAction.color, + ]; + + final alignAction = [ + OptionAction.divider, + OptionAction.align, + ]; + + final List actions = [ + ...standardActions, + if (supportColorBuilderTypes.contains(entry.key)) ...colorAction, + if (supportAlignBuilderType.contains(entry.key)) ...alignAction, + ]; + + if (PlatformExtension.isDesktop) { + builder.showActions = + (node) => node.parent?.type != TableCellBlockKeys.type; + + builder.actionBuilder = (context, state) { + final top = builder.configuration.padding(context.node).top; + final padding = context.node.type == HeadingBlockKeys.type + ? EdgeInsets.only(top: top + 8.0) + : EdgeInsets.only(top: top + 2.0); + return Padding( + padding: padding, + child: BlockActionList( + blockComponentContext: context, + blockComponentState: state, + editorState: editorState, + actions: actions, + showSlashMenu: slashMenuItems != null + ? () => customSlashCommand( + slashMenuItems, + shouldInsertSlash: false, + style: styleCustomizer.selectionMenuStyleBuilder(), + ).handler.call(editorState) + : () {}, + ), + ); + }; + } + } + } + + return builders; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index fb5e89a7dc..5f63347c5e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,7 +1,7 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; @@ -10,6 +10,9 @@ import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.d import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; @@ -104,7 +107,11 @@ class _AppFlowyEditorPageState extends State { late final List slashMenuItems; late final Map blockComponentBuilders = - _customAppFlowyBlockComponentBuilders(); + getEditorBuilderMap( + context: context, + editorState: widget.editorState, + styleCustomizer: widget.styleCustomizer, + ); List get characterShortcutEvents => [ // code block @@ -227,6 +234,7 @@ class _AppFlowyEditorPageState extends State { ); final editorState = widget.editorState; + _setInitialSelection(editorScrollController); if (PlatformExtension.isMobile) { return Column( @@ -280,216 +288,19 @@ class _AppFlowyEditorPageState extends State { ); } - Map _customAppFlowyBlockComponentBuilders() { - final standardActions = [ - OptionAction.delete, - OptionAction.duplicate, - // OptionAction.divider, - // OptionAction.moveUp, - // OptionAction.moveDown, - ]; + void _setInitialSelection(EditorScrollController scrollController) { + final action = getIt().state.action; + final viewId = action?.objectId; + final nodePath = + action?.arguments?[ActionArgumentKeys.nodePath.name] as int?; - const calloutBGColor = Colors.black; - // AFThemeExtension.of(context).calloutBGColor; - - final configuration = BlockComponentConfiguration( - padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), - ); - - final customBlockComponentBuilderMap = { - PageBlockKeys.type: PageBlockComponentBuilder(), - ParagraphBlockKeys.type: ParagraphBlockComponentBuilder( - configuration: configuration, - ), - TodoListBlockKeys.type: TodoListBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => 'To-do', - ), - ), - BulletedListBlockKeys.type: BulletedListBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => 'List', - ), - ), - NumberedListBlockKeys.type: NumberedListBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => 'List', - ), - ), - QuoteBlockKeys.type: QuoteBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderText: (_) => 'Quote', - ), - ), - HeadingBlockKeys.type: HeadingBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0), - placeholderText: (node) => - 'Heading ${node.attributes[HeadingBlockKeys.level]}', - ), - textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level), - ), - ImageBlockKeys.type: CustomImageBlockComponentBuilder( - configuration: configuration, - showMenu: true, - menuBuilder: (Node node, CustomImageBlockComponentState state) => - Positioned( - top: 0, - right: 10, - child: ImageMenu( - node: node, - state: state, - ), - ), - ), - TableBlockKeys.type: TableBlockComponentBuilder( - menuBuilder: (node, editorState, position, dir, onBuild, onClose) => - TableMenu( - node: node, - editorState: editorState, - position: position, - dir: dir, - onBuild: onBuild, - onClose: onClose, - ), - ), - TableCellBlockKeys.type: TableCellBlockComponentBuilder( - menuBuilder: (node, editorState, position, dir, onBuild, onClose) => - TableMenu( - node: node, - editorState: editorState, - position: position, - dir: dir, - onBuild: onBuild, - onClose: onClose, - ), - ), - DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), - ), - DatabaseBlockKeys.boardType: DatabaseViewBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), - ), - DatabaseBlockKeys.calendarType: DatabaseViewBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), - ), - CalloutBlockKeys.type: CalloutBlockComponentBuilder( - configuration: configuration, - defaultColor: calloutBGColor, - ), - DividerBlockKeys.type: DividerBlockComponentBuilder( - configuration: configuration, - height: 28.0, - ), - MathEquationBlockKeys.type: MathEquationBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 20), - ), - ), - CodeBlockKeys.type: CodeBlockComponentBuilder( - configuration: configuration.copyWith( - textStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), - placeholderTextStyle: (_) => styleCustomizer.codeBlockStyleBuilder(), - ), - padding: const EdgeInsets.only( - left: 30, - right: 30, - bottom: 36, - ), - ), - AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(), - SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(), - ToggleListBlockKeys.type: ToggleListBlockComponentBuilder( - configuration: configuration, - ), - OutlineBlockKeys.type: OutlineBlockComponentBuilder( - configuration: configuration.copyWith( - placeholderTextStyle: (_) => - styleCustomizer.outlineBlockPlaceholderStyleBuilder(), - ), - ), - errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( - configuration: configuration.copyWith( - padding: (_) => const EdgeInsets.symmetric(vertical: 10), - ), - ), - }; - - final builders = { - ...standardBlockComponentBuilderMap, - ...customBlockComponentBuilderMap, - }; - - // customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience. - for (final entry in builders.entries) { - if (entry.key == PageBlockKeys.type) { - continue; - } - final builder = entry.value; - - // customize the action builder. - final supportColorBuilderTypes = [ - ParagraphBlockKeys.type, - HeadingBlockKeys.type, - BulletedListBlockKeys.type, - NumberedListBlockKeys.type, - QuoteBlockKeys.type, - TodoListBlockKeys.type, - CalloutBlockKeys.type, - OutlineBlockKeys.type, - ToggleListBlockKeys.type, - ]; - - final supportAlignBuilderType = [ - ImageBlockKeys.type, - ]; - - final colorAction = [ - OptionAction.divider, - OptionAction.color, - ]; - - final alignAction = [ - OptionAction.divider, - OptionAction.align, - ]; - - final List actions = [ - ...standardActions, - if (supportColorBuilderTypes.contains(entry.key)) ...colorAction, - if (supportAlignBuilderType.contains(entry.key)) ...alignAction, - ]; - - // only show the ... and + button on the desktop platform. - if (PlatformExtension.isDesktop) { - builder.showActions = - (node) => node.parent?.type != TableCellBlockKeys.type; - builder.actionBuilder = (context, state) { - final top = builder.configuration.padding(context.node).top; - final padding = context.node.type == HeadingBlockKeys.type - ? EdgeInsets.only(top: top + 8.0) - : EdgeInsets.only(top: top + 2.0); - return Padding( - padding: padding, - child: BlockActionList( - blockComponentContext: context, - blockComponentState: state, - editorState: widget.editorState, - actions: actions, - showSlashMenu: () => showSlashMenu(widget.editorState), - ), - ); - }; - } + if (viewId != null && viewId == documentBloc.view.id && nodePath != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.itemScrollController.jumpTo(index: nodePath); + widget.editorState.selection = + Selection.collapsed(Position(path: [nodePath])); + }); } - - return builders; } List _customSlashMenuItems() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart index d154e71a8b..a3cc07fc40 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -39,6 +39,8 @@ class MentionDateBlock extends StatelessWidget { @override Widget build(BuildContext context) { + final editorState = context.read(); + DateTime? parsedDate = DateTime.tryParse(date); if (parsedDate == null) { return const SizedBox.shrink(); @@ -108,10 +110,12 @@ class MentionDateBlock extends StatelessWidget { ); return GestureDetector( - onTapDown: (details) => DatePickerMenu( - context: context, - editorState: context.read(), - ).show(details.globalPosition, options: options), + onTapDown: editorState.editable + ? (details) => DatePickerMenu( + context: context, + editorState: context.read(), + ).show(details.globalPosition, options: options) + : null, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: MouseRegion( diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart index 79f46c4237..efbe880a8c 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -137,9 +137,7 @@ class ReminderReferenceService { } final viewId = context.read().view.id; - final reminder = _reminderFromDate(date, viewId); - - context.read().add(ReminderEvent.add(reminder: reminder)); + final reminder = _reminderFromDate(date, viewId, node); final transaction = editorState.transaction ..replaceText( @@ -157,6 +155,10 @@ class ReminderReferenceService { ); await editorState.apply(transaction); + + if (context.mounted) { + context.read().add(ReminderEvent.add(reminder: reminder)); + } } void _setOptions() { @@ -204,7 +206,7 @@ class ReminderReferenceService { ); } - ReminderPB _reminderFromDate(DateTime date, String viewId) { + ReminderPB _reminderFromDate(DateTime date, String viewId, Node node) { return ReminderPB( id: nanoid(), objectId: viewId, @@ -212,6 +214,7 @@ class ReminderReferenceService { message: LocaleKeys.reminderNotification_message.tr(), meta: { ReminderMetaKeys.includeTime.name: false.toString(), + ReminderMetaKeys.blockId.name: node.id, }, scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000), isAck: date.isBefore(DateTime.now()), diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index 92d2e47db1..4a34a6d004 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -111,7 +111,7 @@ class ReminderBloc extends Bloc { }, ); }, - pressReminder: (reminderId) { + pressReminder: (reminderId, path) { final reminder = state.reminders.firstWhereOrNull((r) => r.id == reminderId); @@ -120,12 +120,22 @@ class ReminderBloc extends Bloc { } add( - ReminderEvent.update(ReminderUpdate(id: reminderId, isRead: true)), + ReminderEvent.update( + ReminderUpdate( + id: reminderId, + isRead: state.pastReminders.contains(reminder), + ), + ), ); actionBloc.add( NotificationActionEvent.performAction( - action: NotificationAction(objectId: reminder.objectId), + action: NotificationAction( + objectId: reminder.objectId, + arguments: { + ActionArgumentKeys.nodePath.name: path, + }, + ), ), ); }, @@ -191,8 +201,10 @@ class ReminderEvent with _$ReminderEvent { // Mark all unread reminders as read const factory ReminderEvent.markAllRead() = _MarkAllRead; - const factory ReminderEvent.pressReminder({required String reminderId}) = - _PressReminder; + const factory ReminderEvent.pressReminder({ + required String reminderId, + @Default(null) int? path, + }) = _PressReminder; } /// Object used to merge updates with diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart index b169d627ad..0fcdfa5d41 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart @@ -1,7 +1,8 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; enum ReminderMetaKeys { - includeTime("include_time"); + includeTime("include_time"), + blockId("block_id"); const ReminderMetaKeys(this.name); diff --git a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart index 18eca4c721..8ef3e3c2d4 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart @@ -34,7 +34,7 @@ class DocumentExporter { Future> export(DocumentExportType type) async { final documentService = DocumentService(); - final result = await documentService.openDocument(view: view); + final result = await documentService.openDocument(viewId: view.id); return result.fold((error) => left(error), (r) { final document = r.toDocument(); if (document == null) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart index 82b10c1c7e..04720649f9 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart @@ -1,5 +1,6 @@ enum ActionType { openView, + jumpToBlock, } /// A [NotificationAction] is used to communicate with the @@ -10,10 +11,31 @@ enum ActionType { class NotificationAction { const NotificationAction({ this.type = ActionType.openView, + this.arguments, required this.objectId, }); final ActionType type; final String objectId; + final Map? arguments; + + NotificationAction copyWith({ + ActionType? type, + String? objectId, + Map? arguments, + }) => + NotificationAction( + type: type ?? this.type, + objectId: objectId ?? this.objectId, + arguments: arguments ?? this.arguments, + ); +} + +enum ActionArgumentKeys { + nodePath('node_path'); + + final String name; + + const ActionArgumentKeys(this.name); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index e42c8e988a..8e4490b9ce 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -135,3 +135,22 @@ extension ViewLayoutExtension on ViewLayoutPB { } } } + +extension ViewFinder on List { + ViewPB? findView(String id) { + for (final view in this) { + if (view.id == id) { + return view; + } + + if (view.childViews.isNotEmpty) { + final v = view.childViews.findView(id); + if (v != null) { + return v; + } + } + } + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 0bde0c4518..d613e21e09 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -14,7 +14,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -64,23 +63,7 @@ class HomeSideBar extends StatelessWidget { ), ), BlocListener( - listener: (context, state) { - final action = state.action; - if (action != null) { - switch (action.type) { - case ActionType.openView: - final view = context - .read() - .state - .views - .firstWhereOrNull((view) => action.objectId == view.id); - - if (view != null) { - context.read().openPlugin(view); - } - } - } - }, + listener: _onNotificationAction, ), ], child: Builder( @@ -145,4 +128,31 @@ class HomeSideBar extends StatelessWidget { ), ); } + + void _onNotificationAction( + BuildContext context, + NotificationActionState state, + ) { + final action = state.action; + if (action != null) { + if (action.type == ActionType.openView) { + final view = + context.read().state.views.findView(action.objectId); + + if (view != null) { + context.read().openPlugin(view); + + final nodePath = + action.arguments?[ActionArgumentKeys.nodePath.name] as int?; + if (nodePath != null) { + context.read().add( + NotificationActionEvent.performAction( + action: action.copyWith(type: ActionType.jumpToBlock), + ), + ); + } + } + } + } + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart index 8e2f8d6c3e..132fefc022 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -121,17 +121,9 @@ class _NotificationDialogState extends State ); } - void _onAction(ReminderPB reminder) { - final view = widget.views.firstWhereOrNull( - (view) => view.id == reminder.objectId, - ); - - if (view == null) { - return; - } - + void _onAction(ReminderPB reminder, int? path) { _reminderBloc.add( - ReminderEvent.pressReminder(reminderId: reminder.id), + ReminderEvent.pressReminder(reminderId: reminder.id, path: path), ); widget.mutex.close(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart index 63d0fe5a05..52cfb31ac0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart @@ -31,7 +31,7 @@ class NotificationButton extends StatelessWidget { child: AppFlowyPopover( mutex: mutex, direction: PopoverDirection.bottomWithLeftAligned, - constraints: const BoxConstraints(maxHeight: 250, maxWidth: 400), + constraints: const BoxConstraints(maxHeight: 250, maxWidth: 425), windowPadding: EdgeInsets.zero, margin: EdgeInsets.zero, popupBuilder: (_) => diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart index f79c0119e4..3562b614d3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart @@ -1,10 +1,14 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -18,6 +22,8 @@ class NotificationItem extends StatefulWidget { required this.scheduled, required this.body, required this.isRead, + this.path, + this.block, this.includeTime = false, this.readOnly = false, this.onAction, @@ -29,11 +35,20 @@ class NotificationItem extends StatefulWidget { final String title; final Int64 scheduled; final String body; + final bool isRead; + final Future? path; + + /// If [block] is provided, then [body] will be shown only if + /// [block] fails to fetch. + /// + /// [block] is rendered as a result of a [FutureBuilder]. + /// + final Future? block; + final bool includeTime; final bool readOnly; - final bool isRead; - final VoidCallback? onAction; + final void Function(int? path)? onAction; final VoidCallback? onDelete; final void Function(bool isRead)? onReadChanged; @@ -44,6 +59,13 @@ class NotificationItem extends StatefulWidget { class _NotificationItemState extends State { final PopoverMutex mutex = PopoverMutex(); bool _isHovering = false; + int? path; + + @override + void initState() { + super.initState(); + widget.path?.then((p) => path = p); + } @override Widget build(BuildContext context) { @@ -56,61 +78,110 @@ class _NotificationItemState extends State { child: Stack( children: [ GestureDetector( - onTap: widget.onAction, - child: Opacity( - opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, - child: DecoratedBox( - decoration: BoxDecoration( - color: _isHovering && widget.onAction != null - ? AFThemeExtension.of(context).lightGreyHover - : Colors.transparent, - border: widget.isRead || widget.readOnly - ? null - : Border( - left: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.primary, + onTap: () => widget.onAction?.call(path), + child: AbsorbPointer( + child: Opacity( + opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: _isHovering && widget.onAction != null + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent, + border: widget.isRead || widget.readOnly + ? null + : Border( + left: BorderSide( + width: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + FlowySvgs.time_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.tertiary, + ), + const HSpace(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + FlowyText.semibold( + widget.title, + fontSize: 14, + color: Theme.of(context).colorScheme.tertiary, + ), + // TODO(Xazin): Relative time + View Name + FlowyText.regular( + _scheduledString( + widget.scheduled, + widget.includeTime, + ), + fontSize: 10, + ), + const VSpace(5), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + color: Theme.of(context).colorScheme.surface, + ), + child: FutureBuilder( + future: widget.block, + builder: (context, snapshot) { + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data == null) { + return FlowyText.regular( + widget.body, + maxLines: 4, + ); + } + + final EditorState editorState = EditorState( + document: Document(root: snapshot.data!), + ); + + final EditorStyleCustomizer + styleCustomizer = EditorStyleCustomizer( + context: context, + padding: EdgeInsets.zero, + ); + + return Transform.scale( + scale: .9, + alignment: Alignment.centerLeft, + child: AppFlowyEditor( + editorState: editorState, + editorStyle: styleCustomizer.style(), + editable: false, + shrinkWrap: true, + blockComponentBuilders: + getEditorBuilderMap( + context: context, + editorState: editorState, + styleCustomizer: styleCustomizer, + editable: false, + ), + ), + ); + }, + ), + ), + ], ), ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - FlowySvgs.time_s, - size: const Size.square(20), - color: Theme.of(context).colorScheme.tertiary, - ), - const HSpace(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowyText.semibold( - widget.title, - fontSize: 14, - color: Theme.of(context).colorScheme.tertiary, - ), - // TODO(Xazin): Relative time + View Name - FlowyText.regular( - _scheduledString( - widget.scheduled, - widget.includeTime, - ), - fontSize: 10, - ), - const VSpace(5), - FlowyText.regular(widget.body, maxLines: 4), - ], - ), - ), - ], + ], + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart index 334f1c06cd..d8314e4074 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart @@ -1,9 +1,15 @@ +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notifications_hub_empty.dart'; +import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:dartz/dartz.dart'; import 'package:flutter/material.dart'; class NotificationsView extends StatelessWidget { @@ -23,7 +29,7 @@ class NotificationsView extends StatelessWidget { final ReminderBloc reminderBloc; final List views; final bool isUpcoming; - final Function(ReminderPB reminder)? onAction; + final Function(ReminderPB reminder, int? path)? onAction; final Function(ReminderPB reminder)? onDelete; final Function(ReminderPB reminder, bool isRead)? onReadChanged; final Widget? actionBar; @@ -47,19 +53,35 @@ class NotificationsView extends StatelessWidget { if (actionBar != null) actionBar!, ...shownReminders.map( (ReminderPB reminder) { + final blockId = reminder.meta[ReminderMetaKeys.blockId.name]; + + final documentService = DocumentService(); + final documentFuture = documentService.openDocument( + viewId: reminder.objectId, + ); + + Future? nodeBuilder; + Future? pathFinder; + if (blockId != null) { + nodeBuilder = _getNodeFromDocument(documentFuture, blockId); + pathFinder = _getPathFromDocument(documentFuture, blockId); + } + return NotificationItem( reminderId: reminder.id, key: ValueKey(reminder.id), title: reminder.title, scheduled: reminder.scheduledAt, body: reminder.message, + path: pathFinder, + block: nodeBuilder, isRead: reminder.isRead, includeTime: reminder.includeTime ?? false, readOnly: isUpcoming, onReadChanged: (isRead) => onReadChanged?.call(reminder, isRead), onDelete: () => onDelete?.call(reminder), - onAction: () => onAction?.call(reminder), + onAction: (path) => onAction?.call(reminder, path), ); }, ), @@ -67,4 +89,69 @@ class NotificationsView extends StatelessWidget { ), ); } + + Future _getNodeFromDocument( + Future> documentFuture, + String blockId, + ) async { + final document = (await documentFuture).fold( + (l) => null, + (document) => document, + ); + + if (document == null) { + return null; + } + + final blockOrFailure = await DocumentService().getBlockFromDocument( + document: document, + blockId: blockId, + ); + + return blockOrFailure.fold( + (_) => null, + (block) => block.toNode(meta: MetaPB()), + ); + } + + Future _getPathFromDocument( + Future> documentFuture, + String blockId, + ) async { + final document = (await documentFuture).fold( + (l) => null, + (document) => document, + ); + + if (document == null) { + return null; + } + + final rootNode = document.toDocument()?.root; + if (rootNode == null) { + return null; + } + + return _searchById(rootNode, blockId)?.path.first; + } +} + +/// Recursively iterates a [Node] and compares by its [id] +/// +Node? _searchById(Node current, String id) { + if (current.id == id) { + return current; + } + + if (current.children.isNotEmpty) { + for (final child in current.children) { + final node = _searchById(child, id); + + if (node != null) { + return node; + } + } + } + + return null; }