feat: show block in notification item (#3736)

* feat: show block in notification item

* feat: jump to block action

* fix: conflict after merge main

* fix: missing import after merge main
This commit is contained in:
Mathias Mogensen 2023-10-26 03:41:03 +02:00 committed by GitHub
parent aa27c4e6d4
commit 74d9d427bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 654 additions and 350 deletions

View File

@ -130,7 +130,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final result = await UserBackendService.getCurrentUserProfile().then( final result = await UserBackendService.getCurrentUserProfile().then(
(value) async => value.andThen( (value) async => value.andThen(
// open the document // open the document
await _documentService.openDocument(view: view), await _documentService.openDocument(viewId: view.id),
), ),
); );
return state.copyWith( return state.copyWith(

View File

@ -9,7 +9,7 @@ class DocumentService {
Future<Either<FlowyError, Unit>> createDocument({ Future<Either<FlowyError, Unit>> createDocument({
required ViewPB view, required ViewPB view,
}) async { }) async {
final canOpen = await openDocument(view: view); final canOpen = await openDocument(viewId: view.id);
if (canOpen.isRight()) { if (canOpen.isRight()) {
return const Right(unit); return const Right(unit);
} }
@ -19,13 +19,30 @@ class DocumentService {
} }
Future<Either<FlowyError, DocumentDataPB>> openDocument({ Future<Either<FlowyError, DocumentDataPB>> openDocument({
required ViewPB view, required String viewId,
}) async { }) async {
final payload = OpenDocumentPayloadPB()..documentId = view.id; final payload = OpenDocumentPayloadPB()..documentId = viewId;
final result = await DocumentEventOpenDocument(payload).send(); final result = await DocumentEventOpenDocument(payload).send();
return result.swap(); return result.swap();
} }
Future<Either<FlowyError, BlockPB>> 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<Either<FlowyError, Unit>> closeDocument({ Future<Either<FlowyError, Unit>> closeDocument({
required ViewPB view, required ViewPB view,
}) async { }) async {

View File

@ -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/plugins/document/presentation/export_page_widget.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/base64_string.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/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart' import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'
@ -60,39 +62,45 @@ class _DocumentPageState extends State<DocumentPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider.value( return MultiBlocProvider(
value: documentBloc, providers: [
child: BlocBuilder<DocumentBloc, DocumentState>( BlocProvider.value(value: getIt<NotificationActionBloc>()),
builder: (context, state) { BlocProvider.value(value: documentBloc),
return state.loadingState.when( ],
loading: () => child: BlocListener<NotificationActionBloc, NotificationActionState>(
const Center(child: CircularProgressIndicator.adaptive()), listener: _onNotificationAction,
finish: (result) => result.fold( child: BlocBuilder<DocumentBloc, DocumentState>(
(error) { builder: (context, state) {
Log.error(error); return state.loadingState.when(
return FlowyErrorPage.message( loading: () =>
error.toString(), const Center(child: CircularProgressIndicator.adaptive()),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), finish: (result) => result.fold(
); (error) {
}, Log.error(error);
(data) { return FlowyErrorPage.message(
if (state.forceClose) { error.toString(),
widget.onDeleted(); howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
return const SizedBox.shrink();
} else if (documentBloc.editorState == null) {
return Center(
child: ExportPageWidget(
onTap: () async => await _exportPage(data),
),
); );
} else { },
editorState = documentBloc.editorState!; (data) {
return _buildEditorPage(context, state); 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<DocumentPage> {
return Column( return Column(
children: [ children: [
if (state.isDeleted) _buildBanner(context), if (state.isDeleted) _buildBanner(context),
Expanded( Expanded(child: appflowyEditorPage),
child: appflowyEditorPage,
),
], ],
); );
} }
@ -151,4 +157,22 @@ class _DocumentPageState extends State<DocumentPage> {
showSnackBarMessage(context, 'Export success to $path'); showSnackBarMessage(context, 'Export success to $path');
} }
} }
Future<void> _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,
);
}
}
}
} }

View File

@ -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<String, BlockComponentBuilder> getEditorBuilderMap({
required BuildContext context,
required EditorState editorState,
required EditorStyleCustomizer styleCustomizer,
List<SelectionMenuItem>? 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<OptionAction> 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;
}

View File

@ -1,7 +1,7 @@
import 'package:appflowy/plugins/document/application/doc_bloc.dart'; 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/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/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/mention/slash_menu_items.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.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/handlers/reminder_reference.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.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/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
@ -104,7 +107,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
late final List<SelectionMenuItem> slashMenuItems; late final List<SelectionMenuItem> slashMenuItems;
late final Map<String, BlockComponentBuilder> blockComponentBuilders = late final Map<String, BlockComponentBuilder> blockComponentBuilders =
_customAppFlowyBlockComponentBuilders(); getEditorBuilderMap(
context: context,
editorState: widget.editorState,
styleCustomizer: widget.styleCustomizer,
);
List<CharacterShortcutEvent> get characterShortcutEvents => [ List<CharacterShortcutEvent> get characterShortcutEvents => [
// code block // code block
@ -227,6 +234,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
); );
final editorState = widget.editorState; final editorState = widget.editorState;
_setInitialSelection(editorScrollController);
if (PlatformExtension.isMobile) { if (PlatformExtension.isMobile) {
return Column( return Column(
@ -280,216 +288,19 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
); );
} }
Map<String, BlockComponentBuilder> _customAppFlowyBlockComponentBuilders() { void _setInitialSelection(EditorScrollController scrollController) {
final standardActions = [ final action = getIt<NotificationActionBloc>().state.action;
OptionAction.delete, final viewId = action?.objectId;
OptionAction.duplicate, final nodePath =
// OptionAction.divider, action?.arguments?[ActionArgumentKeys.nodePath.name] as int?;
// OptionAction.moveUp,
// OptionAction.moveDown,
];
const calloutBGColor = Colors.black; if (viewId != null && viewId == documentBloc.view.id && nodePath != null) {
// AFThemeExtension.of(context).calloutBGColor; WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.itemScrollController.jumpTo(index: nodePath);
final configuration = BlockComponentConfiguration( widget.editorState.selection =
padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), Selection.collapsed(Position(path: [nodePath]));
); });
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<OptionAction> 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),
),
);
};
}
} }
return builders;
} }
List<SelectionMenuItem> _customSlashMenuItems() { List<SelectionMenuItem> _customSlashMenuItems() {

View File

@ -39,6 +39,8 @@ class MentionDateBlock extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final editorState = context.read<EditorState>();
DateTime? parsedDate = DateTime.tryParse(date); DateTime? parsedDate = DateTime.tryParse(date);
if (parsedDate == null) { if (parsedDate == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -108,10 +110,12 @@ class MentionDateBlock extends StatelessWidget {
); );
return GestureDetector( return GestureDetector(
onTapDown: (details) => DatePickerMenu( onTapDown: editorState.editable
context: context, ? (details) => DatePickerMenu(
editorState: context.read<EditorState>(), context: context,
).show(details.globalPosition, options: options), editorState: context.read<EditorState>(),
).show(details.globalPosition, options: options)
: null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: MouseRegion( child: MouseRegion(

View File

@ -137,9 +137,7 @@ class ReminderReferenceService {
} }
final viewId = context.read<DocumentBloc>().view.id; final viewId = context.read<DocumentBloc>().view.id;
final reminder = _reminderFromDate(date, viewId); final reminder = _reminderFromDate(date, viewId, node);
context.read<ReminderBloc>().add(ReminderEvent.add(reminder: reminder));
final transaction = editorState.transaction final transaction = editorState.transaction
..replaceText( ..replaceText(
@ -157,6 +155,10 @@ class ReminderReferenceService {
); );
await editorState.apply(transaction); await editorState.apply(transaction);
if (context.mounted) {
context.read<ReminderBloc>().add(ReminderEvent.add(reminder: reminder));
}
} }
void _setOptions() { void _setOptions() {
@ -204,7 +206,7 @@ class ReminderReferenceService {
); );
} }
ReminderPB _reminderFromDate(DateTime date, String viewId) { ReminderPB _reminderFromDate(DateTime date, String viewId, Node node) {
return ReminderPB( return ReminderPB(
id: nanoid(), id: nanoid(),
objectId: viewId, objectId: viewId,
@ -212,6 +214,7 @@ class ReminderReferenceService {
message: LocaleKeys.reminderNotification_message.tr(), message: LocaleKeys.reminderNotification_message.tr(),
meta: { meta: {
ReminderMetaKeys.includeTime.name: false.toString(), ReminderMetaKeys.includeTime.name: false.toString(),
ReminderMetaKeys.blockId.name: node.id,
}, },
scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000), scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000),
isAck: date.isBefore(DateTime.now()), isAck: date.isBefore(DateTime.now()),

View File

@ -111,7 +111,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
}, },
); );
}, },
pressReminder: (reminderId) { pressReminder: (reminderId, path) {
final reminder = final reminder =
state.reminders.firstWhereOrNull((r) => r.id == reminderId); state.reminders.firstWhereOrNull((r) => r.id == reminderId);
@ -120,12 +120,22 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
} }
add( add(
ReminderEvent.update(ReminderUpdate(id: reminderId, isRead: true)), ReminderEvent.update(
ReminderUpdate(
id: reminderId,
isRead: state.pastReminders.contains(reminder),
),
),
); );
actionBloc.add( actionBloc.add(
NotificationActionEvent.performAction( 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 // Mark all unread reminders as read
const factory ReminderEvent.markAllRead() = _MarkAllRead; const factory ReminderEvent.markAllRead() = _MarkAllRead;
const factory ReminderEvent.pressReminder({required String reminderId}) = const factory ReminderEvent.pressReminder({
_PressReminder; required String reminderId,
@Default(null) int? path,
}) = _PressReminder;
} }
/// Object used to merge updates with /// Object used to merge updates with

View File

@ -1,7 +1,8 @@
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
enum ReminderMetaKeys { enum ReminderMetaKeys {
includeTime("include_time"); includeTime("include_time"),
blockId("block_id");
const ReminderMetaKeys(this.name); const ReminderMetaKeys(this.name);

View File

@ -34,7 +34,7 @@ class DocumentExporter {
Future<Either<FlowyError, String>> export(DocumentExportType type) async { Future<Either<FlowyError, String>> export(DocumentExportType type) async {
final documentService = DocumentService(); 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) { return result.fold((error) => left(error), (r) {
final document = r.toDocument(); final document = r.toDocument();
if (document == null) { if (document == null) {

View File

@ -1,5 +1,6 @@
enum ActionType { enum ActionType {
openView, openView,
jumpToBlock,
} }
/// A [NotificationAction] is used to communicate with the /// A [NotificationAction] is used to communicate with the
@ -10,10 +11,31 @@ enum ActionType {
class NotificationAction { class NotificationAction {
const NotificationAction({ const NotificationAction({
this.type = ActionType.openView, this.type = ActionType.openView,
this.arguments,
required this.objectId, required this.objectId,
}); });
final ActionType type; final ActionType type;
final String objectId; final String objectId;
final Map<String, dynamic>? arguments;
NotificationAction copyWith({
ActionType? type,
String? objectId,
Map<String, dynamic>? 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);
} }

View File

@ -135,3 +135,22 @@ extension ViewLayoutExtension on ViewLayoutPB {
} }
} }
} }
extension ViewFinder on List<ViewPB> {
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;
}
}

View File

@ -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-folder2/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB; show UserProfilePB;
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -64,23 +63,7 @@ class HomeSideBar extends StatelessWidget {
), ),
), ),
BlocListener<NotificationActionBloc, NotificationActionState>( BlocListener<NotificationActionBloc, NotificationActionState>(
listener: (context, state) { listener: _onNotificationAction,
final action = state.action;
if (action != null) {
switch (action.type) {
case ActionType.openView:
final view = context
.read<MenuBloc>()
.state
.views
.firstWhereOrNull((view) => action.objectId == view.id);
if (view != null) {
context.read<TabsBloc>().openPlugin(view);
}
}
}
},
), ),
], ],
child: Builder( 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<MenuBloc>().state.views.findView(action.objectId);
if (view != null) {
context.read<TabsBloc>().openPlugin(view);
final nodePath =
action.arguments?[ActionArgumentKeys.nodePath.name] as int?;
if (nodePath != null) {
context.read<NotificationActionBloc>().add(
NotificationActionEvent.performAction(
action: action.copyWith(type: ActionType.jumpToBlock),
),
);
}
}
}
}
}
} }

View File

@ -121,17 +121,9 @@ class _NotificationDialogState extends State<NotificationDialog>
); );
} }
void _onAction(ReminderPB reminder) { void _onAction(ReminderPB reminder, int? path) {
final view = widget.views.firstWhereOrNull(
(view) => view.id == reminder.objectId,
);
if (view == null) {
return;
}
_reminderBloc.add( _reminderBloc.add(
ReminderEvent.pressReminder(reminderId: reminder.id), ReminderEvent.pressReminder(reminderId: reminder.id, path: path),
); );
widget.mutex.close(); widget.mutex.close();

View File

@ -31,7 +31,7 @@ class NotificationButton extends StatelessWidget {
child: AppFlowyPopover( child: AppFlowyPopover(
mutex: mutex, mutex: mutex,
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxHeight: 250, maxWidth: 400), constraints: const BoxConstraints(maxHeight: 250, maxWidth: 425),
windowPadding: EdgeInsets.zero, windowPadding: EdgeInsets.zero,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
popupBuilder: (_) => popupBuilder: (_) =>

View File

@ -1,10 +1,14 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.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:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -18,6 +22,8 @@ class NotificationItem extends StatefulWidget {
required this.scheduled, required this.scheduled,
required this.body, required this.body,
required this.isRead, required this.isRead,
this.path,
this.block,
this.includeTime = false, this.includeTime = false,
this.readOnly = false, this.readOnly = false,
this.onAction, this.onAction,
@ -29,11 +35,20 @@ class NotificationItem extends StatefulWidget {
final String title; final String title;
final Int64 scheduled; final Int64 scheduled;
final String body; final String body;
final bool isRead;
final Future<int?>? 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<Node?>? block;
final bool includeTime; final bool includeTime;
final bool readOnly; final bool readOnly;
final bool isRead;
final VoidCallback? onAction; final void Function(int? path)? onAction;
final VoidCallback? onDelete; final VoidCallback? onDelete;
final void Function(bool isRead)? onReadChanged; final void Function(bool isRead)? onReadChanged;
@ -44,6 +59,13 @@ class NotificationItem extends StatefulWidget {
class _NotificationItemState extends State<NotificationItem> { class _NotificationItemState extends State<NotificationItem> {
final PopoverMutex mutex = PopoverMutex(); final PopoverMutex mutex = PopoverMutex();
bool _isHovering = false; bool _isHovering = false;
int? path;
@override
void initState() {
super.initState();
widget.path?.then((p) => path = p);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -56,61 +78,110 @@ class _NotificationItemState extends State<NotificationItem> {
child: Stack( child: Stack(
children: [ children: [
GestureDetector( GestureDetector(
onTap: widget.onAction, onTap: () => widget.onAction?.call(path),
child: Opacity( child: AbsorbPointer(
opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, child: Opacity(
child: DecoratedBox( opacity: widget.isRead && !widget.readOnly ? 0.5 : 1,
decoration: BoxDecoration( child: DecoratedBox(
color: _isHovering && widget.onAction != null decoration: BoxDecoration(
? AFThemeExtension.of(context).lightGreyHover color: _isHovering && widget.onAction != null
: Colors.transparent, ? AFThemeExtension.of(context).lightGreyHover
border: widget.isRead || widget.readOnly : Colors.transparent,
? null border: widget.isRead || widget.readOnly
: Border( ? null
left: BorderSide( : Border(
width: 2, left: BorderSide(
color: Theme.of(context).colorScheme.primary, 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<Node?>(
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),
],
),
),
],
), ),
), ),
), ),

View File

@ -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_extension.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.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/notification_item.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notifications_hub_empty.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-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/reminder.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'; import 'package:flutter/material.dart';
class NotificationsView extends StatelessWidget { class NotificationsView extends StatelessWidget {
@ -23,7 +29,7 @@ class NotificationsView extends StatelessWidget {
final ReminderBloc reminderBloc; final ReminderBloc reminderBloc;
final List<ViewPB> views; final List<ViewPB> views;
final bool isUpcoming; final bool isUpcoming;
final Function(ReminderPB reminder)? onAction; final Function(ReminderPB reminder, int? path)? onAction;
final Function(ReminderPB reminder)? onDelete; final Function(ReminderPB reminder)? onDelete;
final Function(ReminderPB reminder, bool isRead)? onReadChanged; final Function(ReminderPB reminder, bool isRead)? onReadChanged;
final Widget? actionBar; final Widget? actionBar;
@ -47,19 +53,35 @@ class NotificationsView extends StatelessWidget {
if (actionBar != null) actionBar!, if (actionBar != null) actionBar!,
...shownReminders.map( ...shownReminders.map(
(ReminderPB reminder) { (ReminderPB reminder) {
final blockId = reminder.meta[ReminderMetaKeys.blockId.name];
final documentService = DocumentService();
final documentFuture = documentService.openDocument(
viewId: reminder.objectId,
);
Future<Node?>? nodeBuilder;
Future<int?>? pathFinder;
if (blockId != null) {
nodeBuilder = _getNodeFromDocument(documentFuture, blockId);
pathFinder = _getPathFromDocument(documentFuture, blockId);
}
return NotificationItem( return NotificationItem(
reminderId: reminder.id, reminderId: reminder.id,
key: ValueKey(reminder.id), key: ValueKey(reminder.id),
title: reminder.title, title: reminder.title,
scheduled: reminder.scheduledAt, scheduled: reminder.scheduledAt,
body: reminder.message, body: reminder.message,
path: pathFinder,
block: nodeBuilder,
isRead: reminder.isRead, isRead: reminder.isRead,
includeTime: reminder.includeTime ?? false, includeTime: reminder.includeTime ?? false,
readOnly: isUpcoming, readOnly: isUpcoming,
onReadChanged: (isRead) => onReadChanged: (isRead) =>
onReadChanged?.call(reminder, isRead), onReadChanged?.call(reminder, isRead),
onDelete: () => onDelete?.call(reminder), onDelete: () => onDelete?.call(reminder),
onAction: () => onAction?.call(reminder), onAction: (path) => onAction?.call(reminder, path),
); );
}, },
), ),
@ -67,4 +89,69 @@ class NotificationsView extends StatelessWidget {
), ),
); );
} }
Future<Node?> _getNodeFromDocument(
Future<Either<FlowyError, DocumentDataPB>> 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<int?> _getPathFromDocument(
Future<Either<FlowyError, DocumentDataPB>> 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;
} }