Bladeren bron

feat: reminder (#3374)

Mathias Mogensen 1 jaar geleden
bovenliggende
commit
4a433a3176
99 gewijzigde bestanden met toevoegingen van 4599 en 998 verwijderingen
  1. 1 1
      .github/workflows/flutter_ci.yaml
  2. 1 1
      .github/workflows/integration_test.yml
  3. 1 1
      .github/workflows/release.yml
  4. 118 0
      frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart
  5. 2 1
      frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart
  6. 19 0
      frontend/appflowy_flutter/lib/date/date_service.dart
  7. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart
  8. 8 20
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart
  9. 38 28
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart
  10. 4 4
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart
  11. 2 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart
  12. 0 155
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart
  13. 67 6
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart
  14. 181 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart
  15. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart
  16. 34 11
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart
  17. 187 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart
  18. 113 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart
  19. 216 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart
  20. 64 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart
  21. 237 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart
  22. 48 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart
  23. 32 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart
  24. 338 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart
  25. 135 0
      frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart
  26. 6 0
      frontend/appflowy_flutter/lib/startup/deps_resolver.dart
  27. 18 7
      frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart
  28. 232 0
      frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart
  29. 58 0
      frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart
  30. 19 6
      frontend/appflowy_flutter/lib/user/application/user_settings_service.dart
  31. 39 0
      frontend/appflowy_flutter/lib/workspace/application/appearance.dart
  32. 19 0
      frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action.dart
  33. 38 0
      frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action_bloc.dart
  34. 43 0
      frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_service.dart
  35. 41 0
      frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart
  36. 18 0
      frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart
  37. 4 0
      frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart
  38. 36 6
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart
  39. 6 0
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart
  40. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart
  41. 67 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart
  42. 123 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart
  43. 195 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart
  44. 72 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart
  45. 63 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart
  46. 8 9
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart
  47. 277 0
      frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_calendar.dart
  48. 182 0
      frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart
  49. 197 0
      frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart
  50. 4 1
      frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart
  51. 1 2
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart
  52. 8 0
      frontend/appflowy_flutter/pubspec.lock
  53. 5 0
      frontend/appflowy_flutter/pubspec.yaml
  54. 7 0
      frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart
  55. 41 11
      frontend/appflowy_tauri/src-tauri/Cargo.lock
  56. 20 17
      frontend/appflowy_tauri/src-tauri/Cargo.toml
  57. 7 7
      frontend/appflowy_tauri/src/services/backend/index.ts
  58. 39 0
      frontend/resources/translations/en.json
  59. 42 12
      frontend/rust-lib/Cargo.lock
  60. 11 9
      frontend/rust-lib/Cargo.toml
  61. 4 3
      frontend/rust-lib/flowy-core/Cargo.toml
  62. 65 0
      frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs
  63. 48 0
      frontend/rust-lib/flowy-core/src/integrate/log.rs
  64. 3 0
      frontend/rust-lib/flowy-core/src/integrate/mod.rs
  65. 187 0
      frontend/rust-lib/flowy-core/src/integrate/user.rs
  66. 19 238
      frontend/rust-lib/flowy-core/src/lib.rs
  67. 2 0
      frontend/rust-lib/flowy-core/src/module.rs
  68. 1 1
      frontend/rust-lib/flowy-database2/src/services/group/configuration.rs
  69. 25 0
      frontend/rust-lib/flowy-date/Cargo.toml
  70. 3 0
      frontend/rust-lib/flowy-date/Flowy.toml
  71. 10 0
      frontend/rust-lib/flowy-date/build.rs
  72. 13 0
      frontend/rust-lib/flowy-date/src/entities.rs
  73. 36 0
      frontend/rust-lib/flowy-date/src/event_handler.rs
  74. 19 0
      frontend/rust-lib/flowy-date/src/event_map.rs
  75. 4 0
      frontend/rust-lib/flowy-date/src/lib.rs
  76. 1 0
      frontend/rust-lib/flowy-document2/src/lib.rs
  77. 10 0
      frontend/rust-lib/flowy-document2/src/manager.rs
  78. 23 0
      frontend/rust-lib/flowy-document2/src/reminder.rs
  79. 13 8
      frontend/rust-lib/flowy-error/Cargo.toml
  80. 6 0
      frontend/rust-lib/flowy-error/src/errors.rs
  81. 8 2
      frontend/rust-lib/flowy-test/tests/user/local_test/user_awareness_test.rs
  82. 7 6
      frontend/rust-lib/flowy-user/Cargo.toml
  83. 79 0
      frontend/rust-lib/flowy-user/src/entities/date_time.rs
  84. 1 0
      frontend/rust-lib/flowy-user/src/entities/mod.rs
  85. 23 12
      frontend/rust-lib/flowy-user/src/entities/reminder.rs
  86. 28 1
      frontend/rust-lib/flowy-user/src/entities/user_setting.rs
  87. 64 0
      frontend/rust-lib/flowy-user/src/event_handler.rs
  88. 22 3
      frontend/rust-lib/flowy-user/src/event_map.rs
  89. 9 1
      frontend/rust-lib/flowy-user/src/manager.rs
  90. 25 0
      frontend/rust-lib/flowy-user/src/services/collab_interact.rs
  91. 1 0
      frontend/rust-lib/flowy-user/src/services/mod.rs
  92. 45 1
      frontend/rust-lib/flowy-user/src/services/user_awareness.rs
  93. 0 1
      shared-lib/lib-infra/src/lib.rs
  94. 0 218
      shared-lib/lib-infra/src/retry/future.rs
  95. 0 5
      shared-lib/lib-infra/src/retry/mod.rs
  96. 0 127
      shared-lib/lib-infra/src/retry/strategy/exponential_backoff.rs
  97. 0 39
      shared-lib/lib-infra/src/retry/strategy/fixed_interval.rs
  98. 0 5
      shared-lib/lib-infra/src/retry/strategy/jitter.rs
  99. 0 7
      shared-lib/lib-infra/src/retry/strategy/mod.rs

+ 1 - 1
.github/workflows/flutter_ci.yaml

@@ -88,7 +88,7 @@ jobs:
             sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
             sudo apt-get update
             sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
-            sudo apt-get install keybinder-3.0
+            sudo apt-get install keybinder-3.0 libnotify-dev
           elif [ "$RUNNER_OS" == "Windows" ]; then
             vcpkg integrate install
           elif [ "$RUNNER_OS" == "macOS" ]; then

+ 1 - 1
.github/workflows/integration_test.yml

@@ -85,7 +85,7 @@ jobs:
             sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
             sudo apt-get update
             sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
-            sudo apt-get install keybinder-3.0
+            sudo apt-get install keybinder-3.0 libnotify-dev
           elif [ "$RUNNER_OS" == "Windows" ]; then
             vcpkg integrate install
           elif [ "$RUNNER_OS" == "macOS" ]; then

+ 1 - 1
.github/workflows/release.yml

@@ -388,7 +388,7 @@ jobs:
           sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
           sudo apt-get update
           sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
-          sudo apt-get install keybinder-3.0
+          sudo apt-get install keybinder-3.0 libnotify-dev
           sudo apt-get -y install alien
           source $HOME/.cargo/env
           cargo install --force cargo-make

+ 118 - 0
frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart

@@ -0,0 +1,118 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
+import 'package:appflowy/user/application/user_settings_service.dart';
+import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
+import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:calendar_view/calendar_view.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/style_widget/text_field.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import '../util/base.dart';
+import '../util/common_operations.dart';
+import '../util/editor_test_operations.dart';
+import '../util/keyboard.dart';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('Reminder in Document', () {
+    testWidgets('Add reminder for tomorrow, and include time', (tester) async {
+      const time = "23:59";
+
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      final dateTimeSettings =
+          await UserSettingsBackendService().getDateTimeSettings();
+
+      await tester.editor.tapLineOfEditorAt(0);
+      await tester.editor.getCurrentEditorState().insertNewLine();
+
+      await tester.pumpAndSettle();
+
+      // Trigger iline action menu and type 'remind tomorrow'
+      final tomorrow = await _insertReminderTomorrow(tester);
+
+      Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!;
+      Map<String, dynamic> mentionAttr =
+          node.delta!.first.attributes![MentionBlockKeys.mention];
+
+      expect(node.type, 'paragraph');
+      expect(mentionAttr['type'], MentionType.reminder.name);
+      expect(mentionAttr['date'], tomorrow.toIso8601String());
+
+      await tester.tap(
+        find.text(dateTimeSettings.dateFormat.formatDate(tomorrow, false)),
+      );
+      await tester.pumpAndSettle();
+
+      await tester.tap(find.byType(Toggle));
+      await tester.pumpAndSettle();
+
+      await tester.enterText(find.byType(FlowyTextField), time);
+
+      // Leave text field to submit
+      await tester.tap(find.text(LocaleKeys.grid_field_includeTime.tr()));
+      await tester.pumpAndSettle();
+
+      node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!;
+      mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention];
+
+      final tomorrowWithTime =
+          _dateWithTime(dateTimeSettings.timeFormat, tomorrow, time);
+
+      expect(node.type, 'paragraph');
+      expect(mentionAttr['type'], MentionType.reminder.name);
+      expect(mentionAttr['date'], tomorrowWithTime.toIso8601String());
+    });
+  });
+}
+
+Future<DateTime> _insertReminderTomorrow(WidgetTester tester) async {
+  await tester.editor.showAtMenu();
+
+  await FlowyTestKeyboard.simulateKeyDownEvent(
+    [
+      LogicalKeyboardKey.keyR,
+      LogicalKeyboardKey.keyE,
+      LogicalKeyboardKey.keyM,
+      LogicalKeyboardKey.keyI,
+      LogicalKeyboardKey.keyN,
+      LogicalKeyboardKey.keyD,
+      LogicalKeyboardKey.space,
+      LogicalKeyboardKey.keyT,
+      LogicalKeyboardKey.keyO,
+      LogicalKeyboardKey.keyM,
+      LogicalKeyboardKey.keyO,
+      LogicalKeyboardKey.keyR,
+      LogicalKeyboardKey.keyR,
+      LogicalKeyboardKey.keyO,
+      LogicalKeyboardKey.keyW,
+    ],
+    tester: tester,
+  );
+
+  await FlowyTestKeyboard.simulateKeyDownEvent(
+    [LogicalKeyboardKey.enter],
+    tester: tester,
+  );
+
+  return DateTime.now().add(const Duration(days: 1)).withoutTime;
+}
+
+DateTime _dateWithTime(UserTimeFormatPB format, DateTime date, String time) {
+  final t = format == UserTimeFormatPB.TwelveHour
+      ? DateFormat.jm().parse(time)
+      : DateFormat.Hm().parse(time);
+
+  return DateTime.parse(
+    '${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(t.hour)}:${_padZeroLeft(t.minute)}',
+  );
+}
+
+String _padZeroLeft(int a) => a.toString().padLeft(2, '0');

+ 2 - 1
frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart

@@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cus
 import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
+import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
 import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
@@ -160,7 +161,7 @@ class EditorOperations {
   /// Must call [showAtMenu] first.
   Future<void> tapAtMenuItemWithName(String name) async {
     final atMenuItem = find.descendant(
-      of: find.byType(SelectionMenuWidget),
+      of: find.byType(InlineActionsHandler),
       matching: find.text(name, findRichText: true),
     );
     await tester.tapButton(atMenuItem);

+ 19 - 0
frontend/appflowy_flutter/lib/date/date_service.dart

@@ -0,0 +1,19 @@
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/protobuf/flowy-date/entities.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:dartz/dartz.dart';
+
+class DateService {
+  static Future<Either<FlowyError, DateTime>> queryDate(String search) async {
+    final query = DateQueryPB.create()..query = search;
+    final result = (await DateEventQueryDate(query).send()).swap();
+    return result.fold((l) => left(l), (r) {
+      final date = DateTime.tryParse(r.date);
+      if (date != null) {
+        return right(date);
+      }
+
+      return left(FlowyError(msg: 'Could not parse Date (NLP) from String'));
+    });
+  }
+}

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart

@@ -224,7 +224,7 @@ class _SelectOptionColorCell extends StatelessWidget {
 
     final colorIcon = SizedBox.square(
       dimension: 16,
-      child: Container(
+      child: DecoratedBox(
         decoration: BoxDecoration(
           color: color.toColor(context),
           shape: BoxShape.circle,

+ 8 - 20
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart

@@ -3,17 +3,17 @@ import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
 import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
 import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart';
+import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_calendar.dart';
 import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
 import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:dartz/dartz.dart' show Either;
 import 'package:easy_localization/easy_localization.dart';
-
 import 'package:flowy_infra/theme_extension.dart';
-import 'package:flowy_infra/time/duration.dart';
+import 'package:flowy_infra/time/prelude.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -24,9 +24,6 @@ import '../../../../grid/presentation/widgets/common/type_option_separator.dart'
 import '../../../../grid/presentation/widgets/header/type_option/date.dart';
 import 'date_cal_bloc.dart';
 
-final kFirstDay = DateTime.utc(1970, 1, 1);
-final kLastDay = DateTime.utc(2100, 1, 1);
-
 class DateCellEditor extends StatefulWidget {
   final VoidCallback onDismissed;
   final DateCellController cellController;
@@ -51,9 +48,9 @@ class _DateCellEditor extends State<DateCellEditor> {
       builder: (BuildContext context, snapshot) {
         if (snapshot.hasData) {
           return _buildWidget(snapshot);
-        } else {
-          return const SizedBox.shrink();
         }
+
+        return const SizedBox.shrink();
       },
     );
   }
@@ -81,22 +78,14 @@ class _CellCalendarWidget extends StatefulWidget {
   const _CellCalendarWidget({
     required this.cellContext,
     required this.dateTypeOptionPB,
-    Key? key,
-  }) : super(key: key);
+  });
 
   @override
   State<_CellCalendarWidget> createState() => _CellCalendarWidgetState();
 }
 
 class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
-  late PopoverMutex popoverMutex;
-
-  @override
-  void initState() {
-    popoverMutex = PopoverMutex();
-
-    super.initState();
-  }
+  final PopoverMutex popoverMutex = PopoverMutex();
 
   @override
   Widget build(BuildContext context) {
@@ -387,8 +376,7 @@ class _TimeTextField extends StatefulWidget {
     required this.timeStr,
     required this.popoverMutex,
     required this.isEndTime,
-    Key? key,
-  }) : super(key: key);
+  });
 
   @override
   State<_TimeTextField> createState() => _TimeTextFieldState();

+ 38 - 28
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -1,7 +1,11 @@
 import 'package:appflowy/plugins/document/application/doc_bloc.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
 import 'package:appflowy/plugins/document/presentation/editor_style.dart';
+import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
+import 'package:appflowy/plugins/inline_actions/handlers/inline_page_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_service.dart';
 import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
@@ -11,6 +15,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+final List<CommandShortcutEvent> commandShortcutEvents = [
+  toggleToggleListCommand,
+  ...codeBlockCommands,
+  customCopyCommand,
+  customPasteCommand,
+  customCutCommand,
+  ...standardCommandShortcutEvents,
+];
+
+final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
+  ...commandShortcutEvents.map((e) => e.copyWith()).toList(),
+];
+
 /// Wrapper for the appflowy editor.
 class AppFlowyEditorPage extends StatefulWidget {
   const AppFlowyEditorPage({
@@ -34,19 +51,17 @@ class AppFlowyEditorPage extends StatefulWidget {
   State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
 }
 
-final List<CommandShortcutEvent> commandShortcutEvents = [
-  ...codeBlockCommands,
-  ...standardCommandShortcutEvents,
-];
-
-final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
-  ...commandShortcutEvents.map((e) => e.copyWith()).toList(),
-];
-
 class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   late final ScrollController effectiveScrollController;
 
-  final inlinePageReferenceService = InlinePageReferenceService();
+  late final InlineActionsService inlineActionsService = InlineActionsService(
+    context: context,
+    handlers: [
+      InlinePageReferenceService().inlinePageReferenceDelegate,
+      DateReferenceService(context).dateReferenceDelegate,
+      ReminderReferenceService(context).reminderReferenceDelegate,
+    ],
+  );
 
   late final List<CommandShortcutEvent> commandShortcutEvents = [
     toggleToggleListCommand,
@@ -85,9 +100,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
       _customAppFlowyBlockComponentBuilders();
 
   List<CharacterShortcutEvent> get characterShortcutEvents => [
-        // inline page reference list
-        ...inlinePageReferenceShortcuts,
-
         // code block
         ...codeBlockCharacterEvents,
 
@@ -105,19 +117,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
           ..removeWhere(
             (element) => element == slashCommand,
           ), // remove the default slash command.
-      ];
 
-  late final inlinePageReferenceShortcuts = [
-    inlinePageReferenceService.customPageLinkMenu(
-      character: '@',
-      style: styleCustomizer.selectionMenuStyleBuilder(),
-    ),
-    // uncomment this to enable the inline page reference list
-    // inlinePageReferenceService.customPageLinkMenu(
-    //   character: '+',
-    //   style: styleCustomizer.selectionMenuStyleBuilder(),
-    // ),
-  ];
+        /// Inline Actions
+        /// - Reminder
+        /// - Inline-page reference
+        inlineActionsCommand(
+          inlineActionsService,
+          style: styleCustomizer.inlineActionsMenuStyleBuilder(),
+        ),
+      ];
 
   late final showSlashMenu = customSlashCommand(
     slashMenuItems,
@@ -147,6 +155,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
     if (widget.scrollController == null) {
       effectiveScrollController.dispose();
     }
+    inlineActionsService.dispose();
 
     widget.editorState.dispose();
 
@@ -221,6 +230,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
     final configuration = BlockComponentConfiguration(
       padding: (_) => const EdgeInsets.symmetric(vertical: 5.0),
     );
+
     final customBlockComponentBuilderMap = {
       PageBlockKeys.type: PageBlockComponentBuilder(),
       ParagraphBlockKeys.type: TextBlockComponentBuilder(
@@ -462,13 +472,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   }
 
   Future<void> _initializeShortcuts() async {
-    //TODO(Xazin): Refactor lazy initialization
+    // TODO(Xazin): Refactor lazy initialization
     defaultCommandShortcutEvents;
     final settingsShortcutService = SettingsShortcutService();
     final customizeShortcuts =
         await settingsShortcutService.getCustomizeShortcuts();
     await settingsShortcutService.updateCommandShortcuts(
-      standardCommandShortcutEvents,
+      commandShortcutEvents,
       customizeShortcuts,
     );
   }

+ 4 - 4
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart

@@ -180,7 +180,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
               builtInAssetImages[index],
             );
           },
-          child: Container(
+          child: DecoratedBox(
             decoration: BoxDecoration(
               image: DecorationImage(
                 image: AssetImage(builtInAssetImages[index]),
@@ -299,7 +299,7 @@ class NewCustomCoverButton extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return Container(
+    return DecoratedBox(
       decoration: BoxDecoration(
         border: Border.all(
           color: Theme.of(context).colorScheme.primary,
@@ -484,7 +484,7 @@ class _ImageGridItemState extends State<ImageGridItem> {
         children: [
           InkWell(
             onTap: widget.onImageSelect,
-            child: Container(
+            child: DecoratedBox(
               decoration: BoxDecoration(
                 image: DecorationImage(
                   image: FileImage(File(widget.imagePath)),
@@ -544,7 +544,7 @@ class ColorItem extends StatelessWidget {
         padding: const EdgeInsets.only(right: 10.0),
         child: SizedBox.square(
           dimension: 25,
-          child: Container(
+          child: DecoratedBox(
             decoration: BoxDecoration(
               color: option.colorHex.tryToColor(),
               shape: BoxShape.circle,

+ 2 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart

@@ -201,7 +201,7 @@ class CoverImagePreviewWidget extends StatefulWidget {
 
 class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
   _buildFilePickerWidget(BuildContext ctx) {
-    return Container(
+    return DecoratedBox(
       decoration: BoxDecoration(
         color: Theme.of(context).cardColor,
         borderRadius: Corners.s6Border,
@@ -263,7 +263,7 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
         onTap: () {
           ctx.read<CoverImagePickerBloc>().add(const DeleteImage());
         },
-        child: Container(
+        child: DecoratedBox(
           decoration: BoxDecoration(
             shape: BoxShape.circle,
             color: Theme.of(context).colorScheme.onPrimary,

+ 0 - 155
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart

@@ -1,155 +0,0 @@
-import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
-import 'package:appflowy/workspace/application/view/view_ext.dart';
-import 'package:appflowy/workspace/application/view/view_service.dart';
-import 'package:appflowy_editor/appflowy_editor.dart';
-
-enum MentionType {
-  page;
-
-  static MentionType fromString(String value) {
-    switch (value) {
-      case 'page':
-        return page;
-      default:
-        throw UnimplementedError();
-    }
-  }
-}
-
-class MentionBlockKeys {
-  const MentionBlockKeys._();
-
-  static const mention = 'mention';
-  static const type = 'type'; // MentionType, String
-  static const pageId = 'page_id';
-}
-
-class InlinePageReferenceService {
-  customPageLinkMenu({
-    bool shouldInsertKeyword = false,
-    SelectionMenuStyle style = SelectionMenuStyle.light,
-    String character = '@',
-  }) {
-    return CharacterShortcutEvent(
-      key: 'show page link menu',
-      character: character,
-      handler: (editorState) async {
-        final items = await generatePageItems(character);
-        return _showPageSelectionMenu(
-          editorState,
-          items,
-          shouldInsertKeyword: shouldInsertKeyword,
-          style: style,
-          character: character,
-        );
-      },
-    );
-  }
-
-  SelectionMenuService? _selectionMenuService;
-  Future<bool> _showPageSelectionMenu(
-    EditorState editorState,
-    List<SelectionMenuItem> items, {
-    bool shouldInsertKeyword = true,
-    SelectionMenuStyle style = SelectionMenuStyle.light,
-    String character = '@',
-  }) async {
-    if (PlatformExtension.isMobile) {
-      return false;
-    }
-
-    final selection = editorState.selection;
-    if (selection == null) {
-      return false;
-    }
-
-    // delete the selection
-    await editorState.deleteSelection(selection);
-
-    final afterSelection = editorState.selection;
-    if (afterSelection == null || !afterSelection.isCollapsed) {
-      assert(false, 'the selection should be collapsed');
-      return true;
-    }
-    await editorState.insertTextAtPosition(
-      character,
-      position: selection.start,
-    );
-
-    () {
-      final context = editorState.getNodeAtPath(selection.start.path)?.context;
-      if (context != null) {
-        _selectionMenuService = SelectionMenu(
-          context: context,
-          editorState: editorState,
-          selectionMenuItems: items,
-          deleteSlashByDefault: false,
-          style: style,
-          itemCountFilter: 5,
-        );
-        _selectionMenuService?.show();
-      }
-    }();
-
-    return true;
-  }
-
-  Future<List<SelectionMenuItem>> generatePageItems(String character) async {
-    final service = ViewBackendService();
-    final views = await service.fetchViews();
-    if (views.isEmpty) {
-      return [];
-    }
-    final List<SelectionMenuItem> pages = [];
-    views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
-
-    for (final view in views) {
-      final SelectionMenuItem pageSelectionMenuItem = SelectionMenuItem(
-        icon: (editorState, isSelected, style) => SelectableSvgWidget(
-          data: view.iconData,
-          isSelected: isSelected,
-          style: style,
-        ),
-        keywords: [
-          view.name.toLowerCase(),
-        ],
-        name: view.name,
-        handler: (editorState, menuService, context) async {
-          final selection = editorState.selection;
-          if (selection == null || !selection.isCollapsed) {
-            return;
-          }
-          final node = editorState.getNodeAtPath(selection.end.path);
-          final delta = node?.delta;
-          if (node == null || delta == null) {
-            return;
-          }
-          final index = selection.endIndex;
-          final lastKeywordIndex =
-              delta.toPlainText().substring(0, index).lastIndexOf(character);
-          // @page name -> $
-          // preload the page infos
-          pageMemorizer[view.id] = view;
-          final transaction = editorState.transaction
-            ..replaceText(
-              node,
-              lastKeywordIndex,
-              index - lastKeywordIndex,
-              '\$',
-              attributes: {
-                MentionBlockKeys.mention: {
-                  MentionBlockKeys.type: MentionType.page.name,
-                  MentionBlockKeys.pageId: view.id,
-                }
-              },
-            );
-          await editorState.apply(transaction);
-        },
-      );
-      pages.add(pageSelectionMenuItem);
-    }
-
-    return pages;
-  }
-}

+ 67 - 6
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart

@@ -1,22 +1,83 @@
-import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+enum MentionType {
+  page,
+  date,
+  reminder;
+
+  static MentionType fromString(String value) {
+    switch (value) {
+      case 'page':
+        return page;
+      case 'date':
+        return date;
+      case 'reminder':
+        return reminder;
+      default:
+        throw UnimplementedError();
+    }
+  }
+}
+
+class MentionBlockKeys {
+  const MentionBlockKeys._();
+
+  static const uid = 'uid'; // UniqueID
+  static const mention = 'mention';
+  static const type = 'type'; // MentionType, String
+  static const pageId = 'page_id';
+
+  // Related to Reminder and Date blocks
+  static const date = 'date';
+  static const includeTime = 'include_time';
+}
 
 class MentionBlock extends StatelessWidget {
   const MentionBlock({
     super.key,
     required this.mention,
+    required this.node,
+    required this.index,
   });
 
-  final Map mention;
+  final Map<String, dynamic> mention;
+  final Node node;
+  final int index;
 
   @override
   Widget build(BuildContext context) {
     final type = MentionType.fromString(mention[MentionBlockKeys.type]);
-    if (type == MentionType.page) {
-      final pageId = mention[MentionBlockKeys.pageId];
-      return MentionPageBlock(key: ValueKey(pageId), pageId: pageId);
+
+    switch (type) {
+      case MentionType.page:
+        final String pageId = mention[MentionBlockKeys.pageId];
+        return MentionPageBlock(
+          key: ValueKey(pageId),
+          pageId: pageId,
+        );
+      case MentionType.reminder:
+      case MentionType.date:
+        final String date = mention[MentionBlockKeys.date];
+        final BuildContext editorContext =
+            context.read<EditorState>().document.root.context!;
+        return MentionDateBlock(
+          key: ValueKey(date),
+          editorContext: editorContext,
+          date: date,
+          node: node,
+          index: index,
+          isReminder: type == MentionType.reminder,
+          reminderId: type == MentionType.reminder
+              ? mention[MentionBlockKeys.uid]
+              : null,
+          includeTime: mention[MentionBlockKeys.includeTime] ?? false,
+        );
+      default:
+        return const SizedBox.shrink();
     }
-    throw UnimplementedError();
   }
 }

+ 181 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart

@@ -0,0 +1,181 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
+import 'package:appflowy/workspace/application/appearance.dart';
+import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
+import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:calendar_view/calendar_view.dart';
+import 'package:collection/collection.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MentionDateBlock extends StatelessWidget {
+  const MentionDateBlock({
+    super.key,
+    required this.editorContext,
+    required this.date,
+    required this.index,
+    required this.node,
+    this.isReminder = false,
+    this.reminderId,
+    this.includeTime = false,
+  });
+
+  final BuildContext editorContext;
+  final String date;
+  final int index;
+  final Node node;
+
+  final bool isReminder;
+
+  /// If [isReminder] is true, then this must not be
+  /// null or empty
+  final String? reminderId;
+
+  final bool includeTime;
+
+  @override
+  Widget build(BuildContext context) {
+    DateTime? parsedDate = DateTime.tryParse(date);
+    if (parsedDate == null) {
+      return const SizedBox.shrink();
+    }
+
+    final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
+
+    return MultiBlocProvider(
+      providers: [
+        BlocProvider<ReminderBloc>.value(value: context.read<ReminderBloc>()),
+        BlocProvider<AppearanceSettingsCubit>.value(
+          value: context.read<AppearanceSettingsCubit>(),
+        ),
+      ],
+      child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
+        buildWhen: (previous, current) =>
+            previous.dateFormat != current.dateFormat ||
+            previous.timeFormat != current.timeFormat,
+        builder: (context, appearance) =>
+            BlocBuilder<ReminderBloc, ReminderState>(
+          builder: (context, state) {
+            final reminder =
+                state.reminders.firstWhereOrNull((r) => r.id == reminderId);
+            final noReminder = reminder == null && isReminder;
+
+            final formattedDate = appearance.dateFormat
+                .formatDate(parsedDate!, includeTime, appearance.timeFormat);
+
+            final options = DatePickerOptions(
+              selectedDay: parsedDate,
+              focusedDay: parsedDate,
+              firstDay: isReminder
+                  ? noReminder
+                      ? parsedDate
+                      : DateTime.now()
+                  : null,
+              lastDay: noReminder ? parsedDate : null,
+              includeTime: includeTime,
+              timeFormat: appearance.timeFormat,
+              onIncludeTimeChanged: (includeTime) {
+                _updateBlock(parsedDate!.withoutTime, includeTime);
+
+                // We can remove time from the date/reminder
+                //  block when toggled off.
+                if (!includeTime && isReminder) {
+                  _updateScheduledAt(
+                    reminderId: reminderId!,
+                    selectedDay: parsedDate!.withoutTime,
+                  );
+                }
+              },
+              onDaySelected: (selectedDay, focusedDay, includeTime) {
+                parsedDate = selectedDay;
+
+                _updateBlock(selectedDay, includeTime);
+
+                if (isReminder && date != selectedDay.toIso8601String()) {
+                  _updateScheduledAt(
+                    reminderId: reminderId!,
+                    selectedDay: selectedDay,
+                  );
+                }
+              },
+            );
+
+            return GestureDetector(
+              onTapDown: (details) => DatePickerMenu(
+                context: context,
+                editorState: context.read<EditorState>(),
+              ).show(details.globalPosition, options: options),
+              child: Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 4),
+                child: MouseRegion(
+                  cursor: SystemMouseCursors.click,
+                  child: Row(
+                    mainAxisSize: MainAxisSize.min,
+                    children: [
+                      FlowySvg(
+                        isReminder ? FlowySvgs.clock_alarm_s : FlowySvgs.date_s,
+                        size: const Size.square(18.0),
+                        color: isReminder && reminder?.isAck == true
+                            ? Theme.of(context).colorScheme.error
+                            : null,
+                      ),
+                      const HSpace(2),
+                      FlowyText(
+                        formattedDate,
+                        fontSize: fontSize,
+                        color: isReminder && reminder?.isAck == true
+                            ? Theme.of(context).colorScheme.error
+                            : null,
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            );
+          },
+        ),
+      ),
+    );
+  }
+
+  void _updateBlock(
+    DateTime date, [
+    bool includeTime = false,
+  ]) {
+    final editorState = editorContext.read<EditorState>();
+    final transaction = editorState.transaction
+      ..formatText(node, index, 1, {
+        MentionBlockKeys.mention: {
+          MentionBlockKeys.type:
+              isReminder ? MentionType.reminder.name : MentionType.date.name,
+          MentionBlockKeys.date: date.toIso8601String(),
+          MentionBlockKeys.uid: reminderId,
+          MentionBlockKeys.includeTime: includeTime,
+        },
+      });
+
+    editorState.apply(transaction, withUpdateSelection: false);
+
+    // Length of rendered block changes, this synchronizes
+    //  the cursor with the new block render
+    editorState.updateSelectionWithReason(
+      editorState.selection,
+      reason: SelectionUpdateReason.transaction,
+    );
+  }
+
+  void _updateScheduledAt({
+    required String reminderId,
+    required DateTime selectedDay,
+  }) {
+    editorContext.read<ReminderBloc>().add(
+          ReminderEvent.update(
+            ReminderUpdate(id: reminderId, scheduledAt: selectedDay),
+          ),
+        );
+  }
+}

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart

@@ -135,7 +135,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
         ),
       );
     }
-    return Container(
+    return DecoratedBox(
       decoration: BoxDecoration(
         borderRadius: const BorderRadius.all(Radius.circular(8.0)),
         color: backgroundColor,

+ 34 - 11
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart

@@ -1,8 +1,11 @@
 import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
 import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+
+import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
+
 import 'package:appflowy/util/google_font_family_extension.dart';
+
 import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
@@ -161,6 +164,17 @@ class EditorStyleCustomizer {
     );
   }
 
+  InlineActionsMenuStyle inlineActionsMenuStyleBuilder() {
+    final theme = Theme.of(context);
+    return InlineActionsMenuStyle(
+      backgroundColor: theme.cardColor,
+      groupTextColor: theme.colorScheme.onBackground.withOpacity(.8),
+      menuItemTextColor: theme.colorScheme.onBackground,
+      menuItemSelectedColor: theme.hoverColor,
+      menuItemSelectedTextColor: theme.colorScheme.onSurface,
+    );
+  }
+
   FloatingToolbarStyle floatingToolbarStyleBuilder() {
     final theme = Theme.of(context);
     return FloatingToolbarStyle(
@@ -203,19 +217,28 @@ class EditorStyleCustomizer {
       }
     }
 
-    // customize the inline mention block, like inline page
-    final mention = attributes[MentionBlockKeys.mention] as Map?;
+    // Inline Mentions (Page Reference, Date, Reminder, etc.)
+    final mention =
+        attributes[MentionBlockKeys.mention] as Map<String, dynamic>?;
     if (mention != null) {
       final type = mention[MentionBlockKeys.type];
-      if (type == MentionType.page.name) {
-        return WidgetSpan(
-          alignment: PlaceholderAlignment.middle,
-          child: MentionBlock(
-            key: ValueKey(mention[MentionBlockKeys.pageId]),
-            mention: mention,
+      return WidgetSpan(
+        alignment: PlaceholderAlignment.middle,
+        child: MentionBlock(
+          key: ValueKey(
+            switch (type) {
+              MentionType.page => mention[MentionBlockKeys.pageId],
+              MentionType.date ||
+              MentionType.reminder =>
+                mention[MentionBlockKeys.date],
+              _ => MentionBlockKeys.mention,
+            },
           ),
-        );
-      }
+          node: node,
+          index: index,
+          mention: mention,
+        ),
+      );
     }
 
     // customize the inline math equation block

+ 187 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart

@@ -0,0 +1,187 @@
+import 'package:appflowy/date/date_service.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+
+final _keywords = [
+  LocaleKeys.inlineActions_date.tr().toLowerCase(),
+];
+
+class DateReferenceService {
+  DateReferenceService(this.context) {
+    // Initialize locale
+    _locale = context.locale.toLanguageTag();
+
+    // Initializes options
+    _setOptions();
+  }
+
+  final BuildContext context;
+
+  late String _locale;
+  late List<InlineActionsMenuItem> _allOptions;
+
+  List<InlineActionsMenuItem> options = [];
+
+  Future<InlineActionsResult> dateReferenceDelegate([
+    String? search,
+  ]) async {
+    // Checks if Locale has changed since last
+    _setLocale();
+
+    // Filters static options
+    _filterOptions(search);
+
+    // Searches for date by pattern
+    _searchDate(search);
+
+    // Searches for date by natural language prompt
+    await _searchDateNLP(search);
+
+    return InlineActionsResult(
+      title: LocaleKeys.inlineActions_date.tr(),
+      results: options,
+    );
+  }
+
+  void _filterOptions(String? search) {
+    if (search == null || search.isEmpty) {
+      options = _allOptions;
+      return;
+    }
+
+    options = _allOptions
+        .where(
+          (option) =>
+              option.keywords != null &&
+              option.keywords!.isNotEmpty &&
+              option.keywords!.any(
+                (keyword) => keyword.contains(search.toLowerCase()),
+              ),
+        )
+        .toList();
+
+    if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) {
+      options = _allOptions;
+    }
+  }
+
+  void _searchDate(String? search) {
+    if (search == null || search.isEmpty) {
+      return;
+    }
+
+    try {
+      final date = DateFormat.yMd(_locale).parse(search);
+      options.insert(0, _itemFromDate(date));
+    } catch (_) {
+      return;
+    }
+  }
+
+  Future<void> _searchDateNLP(String? search) async {
+    if (search == null || search.isEmpty) {
+      return;
+    }
+
+    final result = await DateService.queryDate(search);
+
+    result.fold(
+      (l) {},
+      (date) => options.insert(0, _itemFromDate(date)),
+    );
+  }
+
+  Future<void> _insertDateReference(
+    EditorState editorState,
+    DateTime date,
+    int start,
+    int end,
+  ) async {
+    final selection = editorState.selection;
+    if (selection == null || !selection.isCollapsed) {
+      return;
+    }
+
+    final node = editorState.getNodeAtPath(selection.end.path);
+    final delta = node?.delta;
+    if (node == null || delta == null) {
+      return;
+    }
+
+    final transaction = editorState.transaction
+      ..replaceText(
+        node,
+        start,
+        end,
+        '\$',
+        attributes: {
+          MentionBlockKeys.mention: {
+            MentionBlockKeys.type: MentionType.date.name,
+            MentionBlockKeys.date: date.toIso8601String(),
+          }
+        },
+      );
+
+    await editorState.apply(transaction);
+  }
+
+  void _setOptions() {
+    final today = DateTime.now();
+    final tomorrow = today.add(const Duration(days: 1));
+    final yesterday = today.subtract(const Duration(days: 1));
+
+    _allOptions = [
+      _itemFromDate(
+        today,
+        LocaleKeys.relativeDates_today.tr(),
+        [DateFormat.yMd(_locale).format(today)],
+      ),
+      _itemFromDate(
+        tomorrow,
+        LocaleKeys.relativeDates_tomorrow.tr(),
+        [DateFormat.yMd(_locale).format(tomorrow)],
+      ),
+      _itemFromDate(
+        yesterday,
+        LocaleKeys.relativeDates_yesterday.tr(),
+        [DateFormat.yMd(_locale).format(yesterday)],
+      ),
+    ];
+  }
+
+  /// Sets Locale on each search to make sure
+  /// keywords are localized
+  void _setLocale() {
+    final locale = context.locale.toLanguageTag();
+
+    if (locale != _locale) {
+      _locale = locale;
+      _setOptions();
+    }
+  }
+
+  InlineActionsMenuItem _itemFromDate(
+    DateTime date, [
+    String? label,
+    List<String>? keywords,
+  ]) {
+    final labelStr = label ?? DateFormat.yMd(_locale).format(date);
+
+    return InlineActionsMenuItem(
+      label: labelStr.capitalize(),
+      keywords: [labelStr.toLowerCase(), ...?keywords],
+      onSelected: (context, editorState, menuService, replace) =>
+          _insertDateReference(
+        editorState,
+        date,
+        replace.$1,
+        replace.$2,
+      ),
+    );
+  }
+}

+ 113 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart

@@ -0,0 +1,113 @@
+import 'dart:async';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy/workspace/application/view/view_service.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:easy_localization/easy_localization.dart';
+
+class InlinePageReferenceService {
+  InlinePageReferenceService() {
+    init();
+  }
+
+  final Completer _initCompleter = Completer<void>();
+
+  late final ViewBackendService service;
+  List<InlineActionsMenuItem> _items = [];
+  List<InlineActionsMenuItem> _filtered = [];
+
+  Future<void> init() async {
+    service = ViewBackendService();
+
+    _generatePageItems().then((value) {
+      _items = value;
+      _filtered = value;
+      _initCompleter.complete();
+    });
+  }
+
+  Future<List<InlineActionsMenuItem>> _filterItems(String? search) async {
+    await _initCompleter.future;
+
+    if (search == null || search.isEmpty) {
+      return _items;
+    }
+
+    return _items
+        .where(
+          (item) =>
+              item.keywords != null &&
+              item.keywords!.isNotEmpty &&
+              item.keywords!.any(
+                (keyword) => keyword.contains(search.toLowerCase()),
+              ),
+        )
+        .toList();
+  }
+
+  Future<InlineActionsResult> inlinePageReferenceDelegate([
+    String? search,
+  ]) async {
+    _filtered = await _filterItems(search);
+
+    return InlineActionsResult(
+      title: LocaleKeys.inlineActions_pageReference.tr(),
+      results: _filtered,
+    );
+  }
+
+  Future<List<InlineActionsMenuItem>> _generatePageItems() async {
+    final views = await service.fetchViews();
+    if (views.isEmpty) {
+      return [];
+    }
+
+    final List<InlineActionsMenuItem> pages = [];
+    views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
+
+    for (final view in views) {
+      final pageSelectionMenuItem = InlineActionsMenuItem(
+        keywords: [view.name.toLowerCase()],
+        label: view.name,
+        onSelected: (context, editorState, menuService, replace) async {
+          final selection = editorState.selection;
+          if (selection == null || !selection.isCollapsed) {
+            return;
+          }
+
+          final node = editorState.getNodeAtPath(selection.end.path);
+          final delta = node?.delta;
+          if (node == null || delta == null) {
+            return;
+          }
+
+          // @page name -> $
+          // preload the page infos
+          pageMemorizer[view.id] = view;
+          final transaction = editorState.transaction
+            ..replaceText(
+              node,
+              replace.$1,
+              replace.$2,
+              '\$',
+              attributes: {
+                MentionBlockKeys.mention: {
+                  MentionBlockKeys.type: MentionType.page.name,
+                  MentionBlockKeys.pageId: view.id,
+                }
+              },
+            );
+
+          await editorState.apply(transaction);
+        },
+      );
+
+      pages.add(pageSelectionMenuItem);
+    }
+
+    return pages;
+  }
+}

+ 216 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart

@@ -0,0 +1,216 @@
+import 'package:appflowy/date/date_service.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/application/doc_bloc.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:fixnum/fixnum.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:nanoid/nanoid.dart';
+
+final _keywords = [
+  LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(),
+  LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(),
+];
+
+class ReminderReferenceService {
+  ReminderReferenceService(this.context) {
+    // Initialize locale
+    _locale = context.locale.toLanguageTag();
+
+    // Initializes options
+    _setOptions();
+  }
+
+  final BuildContext context;
+
+  late String _locale;
+  late List<InlineActionsMenuItem> _allOptions;
+
+  List<InlineActionsMenuItem> options = [];
+
+  Future<InlineActionsResult> reminderReferenceDelegate([
+    String? search,
+  ]) async {
+    // Checks if Locale has changed since last
+    _setLocale();
+
+    // Filters static options
+    _filterOptions(search);
+
+    // Searches for date by pattern
+    _searchDate(search);
+
+    // Searches for date by natural language prompt
+    await _searchDateNLP(search);
+
+    return _groupFromResults(options);
+  }
+
+  InlineActionsResult _groupFromResults([
+    List<InlineActionsMenuItem>? options,
+  ]) =>
+      InlineActionsResult(
+        title: LocaleKeys.inlineActions_reminder_groupTitle.tr(),
+        results: options ?? [],
+        startsWithKeywords: [
+          LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(),
+          LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(),
+        ],
+      );
+
+  void _filterOptions(String? search) {
+    if (search == null || search.isEmpty) {
+      options = _allOptions;
+      return;
+    }
+
+    options = _allOptions
+        .where(
+          (option) =>
+              option.keywords != null &&
+              option.keywords!.isNotEmpty &&
+              option.keywords!.any(
+                (keyword) => keyword.contains(search.toLowerCase()),
+              ),
+        )
+        .toList();
+
+    if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) {
+      options = _allOptions;
+    }
+  }
+
+  void _searchDate(String? search) {
+    if (search == null || search.isEmpty) {
+      return;
+    }
+
+    try {
+      final date = DateFormat.yMd(_locale).parse(search);
+      options.insert(0, _itemFromDate(date));
+    } catch (_) {
+      return;
+    }
+  }
+
+  Future<void> _searchDateNLP(String? search) async {
+    if (search == null || search.isEmpty) {
+      return;
+    }
+
+    final result = await DateService.queryDate(search);
+
+    result.fold(
+      (l) {},
+      (date) {
+        // Only insert dates in the future
+        if (DateTime.now().isBefore(date)) {
+          options.insert(0, _itemFromDate(date));
+        }
+      },
+    );
+  }
+
+  Future<void> _insertReminderReference(
+    EditorState editorState,
+    DateTime date,
+    int start,
+    int end,
+  ) async {
+    final selection = editorState.selection;
+    if (selection == null || !selection.isCollapsed) {
+      return;
+    }
+
+    final node = editorState.getNodeAtPath(selection.end.path);
+    final delta = node?.delta;
+    if (node == null || delta == null) {
+      return;
+    }
+
+    final viewId = context.read<DocumentBloc>().view.id;
+    final reminder = _reminderFromDate(date, viewId);
+
+    context.read<ReminderBloc>().add(ReminderEvent.add(reminder: reminder));
+
+    final transaction = editorState.transaction
+      ..replaceText(
+        node,
+        start,
+        end,
+        '\$',
+        attributes: {
+          MentionBlockKeys.mention: {
+            MentionBlockKeys.type: MentionType.reminder.name,
+            MentionBlockKeys.date: date.toIso8601String(),
+            MentionBlockKeys.uid: reminder.id,
+          }
+        },
+      );
+
+    await editorState.apply(transaction);
+  }
+
+  void _setOptions() {
+    final today = DateTime.now();
+    final tomorrow = today.add(const Duration(days: 1));
+    final oneWeek = today.add(const Duration(days: 7));
+
+    _allOptions = [
+      _itemFromDate(
+        tomorrow,
+        LocaleKeys.relativeDates_tomorrow.tr(),
+        [DateFormat.yMd(_locale).format(tomorrow)],
+      ),
+      _itemFromDate(
+        oneWeek,
+        LocaleKeys.relativeDates_oneWeek.tr(),
+        [DateFormat.yMd(_locale).format(oneWeek)],
+      ),
+    ];
+  }
+
+  /// Sets Locale on each search to make sure
+  /// keywords are localized
+  void _setLocale() {
+    final locale = context.locale.toLanguageTag();
+
+    if (locale != _locale) {
+      _locale = locale;
+      _setOptions();
+    }
+  }
+
+  InlineActionsMenuItem _itemFromDate(
+    DateTime date, [
+    String? label,
+    List<String>? keywords,
+  ]) {
+    final labelStr = label ?? DateFormat.yMd(_locale).format(date);
+
+    return InlineActionsMenuItem(
+      label: labelStr.capitalize(),
+      keywords: [labelStr.toLowerCase(), ...?keywords],
+      onSelected: (context, editorState, menuService, replace) =>
+          _insertReminderReference(editorState, date, replace.$1, replace.$2),
+    );
+  }
+
+  ReminderPB _reminderFromDate(DateTime date, String viewId) {
+    return ReminderPB(
+      id: nanoid(),
+      objectId: viewId,
+      title: LocaleKeys.reminderNotification_title.tr(),
+      message: LocaleKeys.reminderNotification_message.tr(),
+      meta: {"document_id": viewId},
+      scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000),
+      isAck: date.isBefore(DateTime.now()),
+    );
+  }
+}

+ 64 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart

@@ -0,0 +1,64 @@
+import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+const inlineActionCharacter = '@';
+
+CharacterShortcutEvent inlineActionsCommand(
+  InlineActionsService inlineActionsService, {
+  InlineActionsMenuStyle style = const InlineActionsMenuStyle.light(),
+}) =>
+    CharacterShortcutEvent(
+      key: 'Opens Inline Actions Menu',
+      character: inlineActionCharacter,
+      handler: (editorState) => inlineActionsCommandHandler(
+        editorState,
+        inlineActionsService,
+        style,
+      ),
+    );
+
+InlineActionsMenuService? selectionMenuService;
+Future<bool> inlineActionsCommandHandler(
+  EditorState editorState,
+  InlineActionsService service,
+  InlineActionsMenuStyle style,
+) async {
+  final selection = editorState.selection;
+  if (PlatformExtension.isMobile || selection == null) {
+    return false;
+  }
+
+  if (!selection.isCollapsed) {
+    await editorState.deleteSelection(selection);
+  }
+
+  await editorState.insertTextAtPosition(
+    inlineActionCharacter,
+    position: selection.start,
+  );
+
+  final List<InlineActionsResult> initialResults = [];
+  for (final handler in service.handlers) {
+    final group = await handler();
+
+    if (group.results.isNotEmpty) {
+      initialResults.add(group);
+    }
+  }
+
+  if (service.context != null) {
+    selectionMenuService = InlineActionsMenu(
+      context: service.context!,
+      editorState: editorState,
+      service: service,
+      initialResults: initialResults,
+      style: style,
+    );
+
+    selectionMenuService?.show();
+  }
+
+  return true;
+}

+ 237 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart

@@ -0,0 +1,237 @@
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
+import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+abstract class InlineActionsMenuService {
+  InlineActionsMenuStyle get style;
+
+  void show();
+  void dismiss();
+}
+
+class InlineActionsMenu extends InlineActionsMenuService {
+  InlineActionsMenu({
+    required this.context,
+    required this.editorState,
+    required this.service,
+    required this.initialResults,
+    required this.style,
+  });
+
+  final BuildContext context;
+  final EditorState editorState;
+  final InlineActionsService service;
+  final List<InlineActionsResult> initialResults;
+
+  @override
+  final InlineActionsMenuStyle style;
+
+  OverlayEntry? _menuEntry;
+  bool selectionChangedByMenu = false;
+
+  @override
+  void dismiss() {
+    if (_menuEntry != null) {
+      editorState.service.keyboardService?.enable();
+      editorState.service.scrollService?.enable();
+    }
+
+    _menuEntry?.remove();
+    _menuEntry = null;
+
+    // workaround: SelectionService has been released after hot reload.
+    final isSelectionDisposed =
+        editorState.service.selectionServiceKey.currentState == null;
+    if (!isSelectionDisposed) {
+      final selectionService = editorState.service.selectionService;
+      selectionService.currentSelection.removeListener(_onSelectionChange);
+    }
+  }
+
+  void _onSelectionUpdate() => selectionChangedByMenu = true;
+
+  @override
+  void show() {
+    WidgetsBinding.instance.addPostFrameCallback((_) => _show());
+  }
+
+  void _show() {
+    dismiss();
+
+    final selectionService = editorState.service.selectionService;
+    final selectionRects = selectionService.selectionRects;
+    if (selectionRects.isEmpty) {
+      return;
+    }
+
+    const double menuHeight = 200.0;
+    const Offset menuOffset = Offset(0, 10);
+    final Offset editorOffset =
+        editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
+    final Size editorSize = editorState.renderBox!.size;
+
+    // Default to opening the overlay below
+    Alignment alignment = Alignment.topLeft;
+
+    final firstRect = selectionRects.first;
+    Offset offset = firstRect.bottomRight + menuOffset;
+
+    // Show above
+    if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) {
+      offset = firstRect.topRight - menuOffset;
+      alignment = Alignment.bottomLeft;
+
+      offset = Offset(
+        offset.dx,
+        MediaQuery.of(context).size.height - offset.dy,
+      );
+    }
+
+    // Show on the left
+    if (offset.dx > editorSize.width / 2) {
+      alignment = alignment == Alignment.topLeft
+          ? Alignment.topRight
+          : Alignment.bottomRight;
+
+      offset = Offset(
+        editorSize.width - offset.dx,
+        offset.dy,
+      );
+    }
+
+    final (left, top, right, bottom) = _getPosition(alignment, offset);
+
+    _menuEntry = OverlayEntry(
+      builder: (context) => SizedBox(
+        height: editorSize.height,
+        width: editorSize.width,
+
+        // GestureDetector handles clicks outside of the context menu,
+        // to dismiss the context menu.
+        child: GestureDetector(
+          behavior: HitTestBehavior.opaque,
+          onTap: dismiss,
+          child: Stack(
+            children: [
+              Positioned(
+                top: top,
+                bottom: bottom,
+                left: left,
+                right: right,
+                child: SingleChildScrollView(
+                  scrollDirection: Axis.horizontal,
+                  child: InlineActionsHandler(
+                    service: service,
+                    results: initialResults,
+                    editorState: editorState,
+                    menuService: this,
+                    onDismiss: dismiss,
+                    onSelectionUpdate: _onSelectionUpdate,
+                    style: style,
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    Overlay.of(context).insert(_menuEntry!);
+
+    editorState.service.keyboardService?.disable(showCursor: true);
+    editorState.service.scrollService?.disable();
+    selectionService.currentSelection.addListener(_onSelectionChange);
+  }
+
+  void _onSelectionChange() {
+    // workaround: SelectionService has been released after hot reload.
+    final isSelectionDisposed =
+        editorState.service.selectionServiceKey.currentState == null;
+    if (!isSelectionDisposed) {
+      final selectionService = editorState.service.selectionService;
+      if (selectionService.currentSelection.value == null) {
+        return;
+      }
+    }
+
+    if (!selectionChangedByMenu) {
+      return dismiss();
+    }
+
+    selectionChangedByMenu = false;
+  }
+
+  (double? left, double? top, double? right, double? bottom) _getPosition(
+    Alignment alignment,
+    Offset offset,
+  ) {
+    double? left, top, right, bottom;
+    switch (alignment) {
+      case Alignment.topLeft:
+        left = offset.dx;
+        top = offset.dy;
+        break;
+      case Alignment.bottomLeft:
+        left = offset.dx;
+        bottom = offset.dy;
+        break;
+      case Alignment.topRight:
+        right = offset.dx;
+        top = offset.dy;
+        break;
+      case Alignment.bottomRight:
+        right = offset.dx;
+        bottom = offset.dy;
+        break;
+    }
+
+    return (left, top, right, bottom);
+  }
+}
+
+class InlineActionsMenuStyle {
+  InlineActionsMenuStyle({
+    required this.backgroundColor,
+    required this.groupTextColor,
+    required this.menuItemTextColor,
+    required this.menuItemSelectedColor,
+    required this.menuItemSelectedTextColor,
+  });
+
+  const InlineActionsMenuStyle.light()
+      : backgroundColor = Colors.white,
+        groupTextColor = const Color(0xFF555555),
+        menuItemTextColor = const Color(0xFF333333),
+        menuItemSelectedColor = const Color(0xFFE0F8FF),
+        menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
+
+  const InlineActionsMenuStyle.dark()
+      : backgroundColor = const Color(0xFF282E3A),
+        groupTextColor = const Color(0xFFBBC3CD),
+        menuItemTextColor = const Color(0xFFBBC3CD),
+        menuItemSelectedColor = const Color(0xFF00BCF0),
+        menuItemSelectedTextColor = const Color(0xFF131720);
+
+  /// The background color of the context menu itself
+  ///
+  final Color backgroundColor;
+
+  /// The color of the [InlineActionsGroup]'s title text
+  ///
+  final Color groupTextColor;
+
+  /// The text color of an [InlineActionsMenuItem]
+  ///
+  final Color menuItemTextColor;
+
+  /// The background of the currently selected [InlineActionsMenuItem]
+  ///
+  final Color menuItemSelectedColor;
+
+  /// The text color of the currently selected [InlineActionsMenuItem]
+  ///
+  final Color menuItemSelectedTextColor;
+}

+ 48 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart

@@ -0,0 +1,48 @@
+import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+typedef SelectItemHandler = void Function(
+  BuildContext context,
+  EditorState editorState,
+  InlineActionsMenuService menuService,
+  (int start, int end) replacement,
+);
+
+class InlineActionsMenuItem {
+  InlineActionsMenuItem({
+    required this.label,
+    this.icon,
+    this.keywords,
+    this.onSelected,
+  });
+
+  final String label;
+  final Widget Function(bool onSelected)? icon;
+  final List<String>? keywords;
+  final SelectItemHandler? onSelected;
+}
+
+class InlineActionsResult {
+  InlineActionsResult({
+    required this.title,
+    required this.results,
+    this.startsWithKeywords,
+  });
+
+  /// Localized title to be displayed above the results
+  /// of the current group.
+  ///
+  final String title;
+
+  /// List of results that will be displayed for this group
+  /// made up of [SelectionMenuItem]s.
+  ///
+  final List<InlineActionsMenuItem> results;
+
+  /// If the search term start with one of these keyword,
+  /// the results will be reordered such that these results
+  /// will be above.
+  ///
+  final List<String>? startsWithKeywords;
+}

+ 32 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart

@@ -0,0 +1,32 @@
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:flutter/material.dart';
+
+typedef InlineActionsDelegate = Future<InlineActionsResult> Function([
+  String? search,
+]);
+
+abstract class _InlineActionsProvider {
+  void dispose();
+}
+
+class InlineActionsService extends _InlineActionsProvider {
+  InlineActionsService({
+    required this.context,
+    required this.handlers,
+  });
+
+  /// The [BuildContext] in which to show the [InlineActionsMenu]
+  ///
+  BuildContext? context;
+
+  final List<InlineActionsDelegate> handlers;
+
+  /// This is a workaround for not having a mounted check.
+  /// Thus when the widget that uses the service is disposed,
+  /// we set the [BuildContext] to null.
+  ///
+  @override
+  void dispose() {
+    context = null;
+  }
+}

+ 338 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart

@@ -0,0 +1,338 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
+import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_menu_group.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+extension _StartWithsSort on List<InlineActionsResult> {
+  void sortByStartsWithKeyword(String search) => sort(
+        (a, b) {
+          final aCount = a.startsWithKeywords
+                  ?.where(
+                    (key) => search.toLowerCase().startsWith(key),
+                  )
+                  .length ??
+              0;
+
+          final bCount = b.startsWithKeywords
+                  ?.where(
+                    (key) => search.toLowerCase().startsWith(key),
+                  )
+                  .length ??
+              0;
+
+          if (aCount > bCount) {
+            return -1;
+          } else if (bCount > aCount) {
+            return 1;
+          }
+
+          return 0;
+        },
+      );
+}
+
+const _invalidSearchesAmount = 20;
+
+class InlineActionsHandler extends StatefulWidget {
+  const InlineActionsHandler({
+    super.key,
+    required this.service,
+    required this.results,
+    required this.editorState,
+    required this.menuService,
+    required this.onDismiss,
+    required this.onSelectionUpdate,
+    required this.style,
+  });
+
+  final InlineActionsService service;
+  final List<InlineActionsResult> results;
+  final EditorState editorState;
+  final InlineActionsMenuService menuService;
+  final VoidCallback onDismiss;
+  final VoidCallback onSelectionUpdate;
+  final InlineActionsMenuStyle style;
+
+  @override
+  State<InlineActionsHandler> createState() => _InlineActionsHandlerState();
+}
+
+class _InlineActionsHandlerState extends State<InlineActionsHandler> {
+  final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
+
+  late List<InlineActionsResult> results = widget.results;
+  int invalidCounter = 0;
+  late int startOffset;
+
+  String _search = '';
+  set search(String search) {
+    _search = search;
+    _doSearch();
+  }
+
+  Future<void> _doSearch() async {
+    final List<InlineActionsResult> newResults = [];
+    for (final handler in widget.service.handlers) {
+      final group = await handler.call(_search);
+
+      if (group.results.isNotEmpty) {
+        newResults.add(group);
+      }
+    }
+
+    invalidCounter = results.every((group) => group.results.isEmpty)
+        ? invalidCounter + 1
+        : 0;
+
+    if (invalidCounter >= _invalidSearchesAmount) {
+      return widget.onDismiss();
+    }
+
+    _resetSelection();
+
+    newResults.sortByStartsWithKeyword(_search);
+
+    setState(() {
+      results = newResults;
+    });
+  }
+
+  void _resetSelection() {
+    _selectedGroup = 0;
+    _selectedIndex = 0;
+  }
+
+  int _selectedGroup = 0;
+  int _selectedIndex = 0;
+
+  @override
+  void initState() {
+    super.initState();
+
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      _focusNode.requestFocus();
+    });
+
+    startOffset = widget.editorState.selection?.endIndex ?? 0;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Focus(
+      focusNode: _focusNode,
+      onKey: onKey,
+      child: DecoratedBox(
+        decoration: BoxDecoration(
+          color: widget.style.backgroundColor,
+          borderRadius: BorderRadius.circular(6.0),
+          boxShadow: [
+            BoxShadow(
+              blurRadius: 5,
+              spreadRadius: 1,
+              color: Colors.black.withOpacity(0.1),
+            ),
+          ],
+        ),
+        child: Padding(
+          padding: const EdgeInsets.all(8.0),
+          child: noResults
+              ? SizedBox(
+                  width: 150,
+                  child: FlowyText.regular(
+                    LocaleKeys.inlineActions_noResults.tr(),
+                  ),
+                )
+              : Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: results
+                      .where((g) => g.results.isNotEmpty)
+                      .mapIndexed(
+                        (index, group) => InlineActionsGroup(
+                          result: group,
+                          editorState: widget.editorState,
+                          menuService: widget.menuService,
+                          style: widget.style,
+                          isGroupSelected: _selectedGroup == index,
+                          selectedIndex: _selectedIndex,
+                          onSelected: widget.onDismiss,
+                        ),
+                      )
+                      .toList(),
+                ),
+        ),
+      ),
+    );
+  }
+
+  bool get noResults =>
+      results.isEmpty || results.every((e) => e.results.isEmpty);
+
+  int get groupLength => results.length;
+
+  int lengthOfGroup(int index) => results[index].results.length;
+
+  InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) =>
+      results[groupIndex].results[handlerIndex];
+
+  KeyEventResult onKey(focus, event) {
+    if (event is! RawKeyDownEvent) {
+      return KeyEventResult.ignored;
+    }
+
+    const moveKeys = [
+      LogicalKeyboardKey.arrowUp,
+      LogicalKeyboardKey.arrowDown,
+      LogicalKeyboardKey.tab,
+    ];
+
+    if (event.logicalKey == LogicalKeyboardKey.enter) {
+      if (_selectedGroup <= groupLength &&
+          _selectedIndex <= lengthOfGroup(_selectedGroup)) {
+        handlerOf(_selectedGroup, _selectedIndex).onSelected?.call(
+          context,
+          widget.editorState,
+          widget.menuService,
+          (startOffset - 1, _search.length + 1),
+        );
+
+        widget.onDismiss();
+        return KeyEventResult.handled;
+      }
+    } else if (event.logicalKey == LogicalKeyboardKey.escape) {
+      widget.onDismiss();
+      return KeyEventResult.handled;
+    } else if (event.logicalKey == LogicalKeyboardKey.backspace) {
+      if (_search.isEmpty) {
+        widget.onDismiss();
+        widget.editorState.deleteBackward(); // Delete '@'
+      } else {
+        widget.onSelectionUpdate();
+        widget.editorState.deleteBackward();
+        _deleteCharacterAtSelection();
+      }
+
+      return KeyEventResult.handled;
+    } else if (event.character != null &&
+        ![
+          ...moveKeys,
+          LogicalKeyboardKey.arrowLeft,
+          LogicalKeyboardKey.arrowRight
+        ].contains(event.logicalKey)) {
+      /// Prevents dismissal of context menu by notifying the parent
+      /// that the selection change occurred from the handler.
+      widget.onSelectionUpdate();
+
+      // Interpolation to avoid having a getter for private variable
+      _insertCharacter(event.character!);
+      return KeyEventResult.handled;
+    }
+
+    if (moveKeys.contains(event.logicalKey)) {
+      _moveSelection(event.logicalKey);
+      return KeyEventResult.handled;
+    }
+
+    if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]
+        .contains(event.logicalKey)) {
+      widget.onSelectionUpdate();
+
+      event.logicalKey == LogicalKeyboardKey.arrowLeft
+          ? widget.editorState.moveCursorForward(SelectionMoveRange.character)
+          : widget.editorState.moveCursorBackward(SelectionMoveRange.character);
+
+      /// If cursor moves before @ then dismiss menu
+      /// If cursor moves after @search.length then dismiss menu
+      final selection = widget.editorState.selection;
+      if (selection != null &&
+          (selection.endIndex < startOffset ||
+              selection.endIndex > (startOffset + _search.length))) {
+        widget.onDismiss();
+      }
+
+      /// Workaround: When using the move cursor methods, it seems the
+      ///  focus goes back to the editor, this makes sure this handler
+      ///  receives the next keypress.
+      ///
+      _focusNode.requestFocus();
+
+      return KeyEventResult.handled;
+    }
+
+    return KeyEventResult.handled;
+  }
+
+  void _insertCharacter(String character) {
+    widget.editorState.insertTextAtCurrentSelection(character);
+
+    final selection = widget.editorState.selection;
+    if (selection == null || !selection.isCollapsed) {
+      return;
+    }
+
+    final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta;
+    if (delta == null) {
+      return;
+    }
+
+    /// Grab index of the first character in command (right after @)
+    final startIndex =
+        delta.toPlainText().lastIndexOf(inlineActionCharacter) + 1;
+
+    search = widget.editorState
+        .getTextInSelection(
+          selection.copyWith(
+            start: selection.start.copyWith(offset: startIndex),
+            end: selection.start
+                .copyWith(offset: startIndex + _search.length + 1),
+          ),
+        )
+        .join();
+  }
+
+  void _moveSelection(LogicalKeyboardKey key) {
+    if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab].contains(key)) {
+      if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) {
+        _selectedIndex += 1;
+      } else if (_selectedGroup < groupLength - 1) {
+        _selectedGroup += 1;
+        _selectedIndex = 0;
+      }
+    } else if (key == LogicalKeyboardKey.arrowUp) {
+      if (_selectedIndex == 0 && _selectedGroup > 0) {
+        _selectedGroup -= 1;
+        _selectedIndex = lengthOfGroup(_selectedGroup) - 1;
+      } else if (_selectedIndex > 0) {
+        _selectedIndex -= 1;
+      }
+    }
+
+    if (mounted) {
+      setState(() {});
+    }
+  }
+
+  void _deleteCharacterAtSelection() {
+    final selection = widget.editorState.selection;
+    if (selection == null || !selection.isCollapsed) {
+      return;
+    }
+
+    final node = widget.editorState.getNodeAtPath(selection.end.path);
+    final delta = node?.delta;
+    if (node == null || delta == null) {
+      return;
+    }
+
+    search = delta
+        .toPlainText()
+        .substring(startOffset, startOffset - 1 + _search.length);
+  }
+}

+ 135 - 0
frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart

@@ -0,0 +1,135 @@
+import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
+import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:collection/collection.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+
+class InlineActionsGroup extends StatelessWidget {
+  const InlineActionsGroup({
+    super.key,
+    required this.result,
+    required this.editorState,
+    required this.menuService,
+    required this.style,
+    required this.onSelected,
+    this.isGroupSelected = false,
+    this.selectedIndex = 0,
+  });
+
+  final InlineActionsResult result;
+  final EditorState editorState;
+  final InlineActionsMenuService menuService;
+  final InlineActionsMenuStyle style;
+  final VoidCallback onSelected;
+
+  final bool isGroupSelected;
+  final int selectedIndex;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 6),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          FlowyText.medium(result.title, color: style.groupTextColor),
+          const SizedBox(height: 4),
+          ...result.results.mapIndexed(
+            (index, item) => InlineActionsWidget(
+              item: item,
+              editorState: editorState,
+              menuService: menuService,
+              isSelected: isGroupSelected && index == selectedIndex,
+              style: style,
+              onSelected: onSelected,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class InlineActionsWidget extends StatefulWidget {
+  const InlineActionsWidget({
+    super.key,
+    required this.item,
+    required this.editorState,
+    required this.menuService,
+    required this.isSelected,
+    required this.style,
+    required this.onSelected,
+  });
+
+  final InlineActionsMenuItem item;
+  final EditorState editorState;
+  final InlineActionsMenuService menuService;
+  final bool isSelected;
+  final InlineActionsMenuStyle style;
+  final VoidCallback onSelected;
+
+  @override
+  State<InlineActionsWidget> createState() => _InlineActionsWidgetState();
+}
+
+class _InlineActionsWidgetState extends State<InlineActionsWidget> {
+  bool isHovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(vertical: 2),
+      child: SizedBox(
+        width: 200,
+        child: widget.item.icon != null
+            ? TextButton.icon(
+                onPressed: _onPressed,
+                style: ButtonStyle(
+                  alignment: Alignment.centerLeft,
+                  backgroundColor: widget.isSelected
+                      ? MaterialStateProperty.all(
+                          widget.style.menuItemSelectedColor,
+                        )
+                      : MaterialStateProperty.all(Colors.transparent),
+                ),
+                icon: widget.item.icon!.call(widget.isSelected || isHovering),
+                label: FlowyText.regular(
+                  widget.item.label,
+                  color: widget.isSelected
+                      ? widget.style.menuItemSelectedTextColor
+                      : widget.style.menuItemTextColor,
+                ),
+              )
+            : TextButton(
+                onPressed: _onPressed,
+                style: ButtonStyle(
+                  alignment: Alignment.centerLeft,
+                  backgroundColor: widget.isSelected
+                      ? MaterialStateProperty.all(
+                          widget.style.menuItemSelectedColor,
+                        )
+                      : MaterialStateProperty.all(Colors.transparent),
+                ),
+                onHover: (value) => setState(() => isHovering = value),
+                child: FlowyText.regular(
+                  widget.item.label,
+                  color: widget.isSelected
+                      ? widget.style.menuItemSelectedTextColor
+                      : widget.style.menuItemTextColor,
+                ),
+              ),
+      ),
+    );
+  }
+
+  void _onPressed() {
+    widget.onSelected();
+    widget.item.onSelected?.call(
+      context,
+      widget.editorState,
+      widget.menuService,
+      (0, 0),
+    );
+  }
+}

+ 6 - 0
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -15,11 +15,13 @@ import 'package:appflowy/user/application/auth/auth_service.dart';
 import 'package:appflowy/user/application/auth/mock_auth_service.dart';
 import 'package:appflowy/user/application/auth/supabase_auth_service.dart';
 import 'package:appflowy/user/application/prelude.dart';
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
 import 'package:appflowy/user/application/user_listener.dart';
 import 'package:appflowy/user/application/user_service.dart';
 import 'package:appflowy/user/presentation/router.dart';
 import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
 import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
 import 'package:appflowy/workspace/application/settings/prelude.dart';
 import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/user/prelude.dart';
@@ -133,7 +135,11 @@ void _resolveHomeDeps(GetIt getIt) {
     (view, _) => DocShareBloc(view: view),
   );
 
+  getIt.registerSingleton<NotificationActionBloc>(NotificationActionBloc());
+
   getIt.registerLazySingleton<TabsBloc>(() => TabsBloc());
+
+  getIt.registerSingleton<ReminderBloc>(ReminderBloc());
 }
 
 void _resolveFolderDeps(GetIt getIt) {

+ 18 - 7
frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_service.dart';
 import 'package:appflowy/startup/tasks/prelude.dart';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@@ -22,15 +23,22 @@ class InitAppWidgetTask extends LaunchTask {
 
   @override
   Future<void> initialize(LaunchContext context) async {
+    WidgetsFlutterBinding.ensureInitialized();
+
+    await NotificationService.initialize();
+
     final widget = context.getIt<EntryPoint>().create(context.config);
     final appearanceSetting =
         await UserSettingsBackendService().getAppearanceSetting();
+    final dateTimeSettings =
+        await UserSettingsBackendService().getDateTimeSettings();
 
     // If the passed-in context is not the same as the context of the
     // application widget, the application widget will be rebuilt.
     final app = ApplicationWidget(
       key: ValueKey(context),
       appearanceSetting: appearanceSetting,
+      dateTimeSettings: dateTimeSettings,
       appTheme: await appTheme(appearanceSetting.theme),
       child: widget,
     );
@@ -71,21 +79,23 @@ class InitAppWidgetTask extends LaunchTask {
       ),
     );
 
-    return Future(() => {});
+    return;
   }
 }
 
 class ApplicationWidget extends StatefulWidget {
-  final Widget child;
-  final AppearanceSettingsPB appearanceSetting;
-  final AppTheme appTheme;
-
   const ApplicationWidget({
-    Key? key,
+    super.key,
     required this.child,
     required this.appTheme,
     required this.appearanceSetting,
-  }) : super(key: key);
+    required this.dateTimeSettings,
+  });
+
+  final Widget child;
+  final AppearanceSettingsPB appearanceSetting;
+  final AppTheme appTheme;
+  final DateTimeSettingsPB dateTimeSettings;
 
   @override
   State<ApplicationWidget> createState() => _ApplicationWidgetState();
@@ -109,6 +119,7 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
         BlocProvider<AppearanceSettingsCubit>(
           create: (_) => AppearanceSettingsCubit(
             widget.appearanceSetting,
+            widget.dateTimeSettings,
             widget.appTheme,
           )..readLocaleWhenAppLaunch(context),
         ),

+ 232 - 0
frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart

@@ -0,0 +1,232 @@
+import 'dart:async';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/user/application/reminder/reminder_service.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_action.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_service.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
+import 'package:bloc/bloc.dart';
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:fixnum/fixnum.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'reminder_bloc.freezed.dart';
+
+class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
+  late final NotificationActionBloc actionBloc;
+  late final ReminderService reminderService;
+  late final Timer timer;
+
+  ReminderBloc() : super(ReminderState()) {
+    actionBloc = getIt<NotificationActionBloc>();
+    reminderService = const ReminderService();
+    timer = _periodicCheck();
+
+    on<ReminderEvent>((event, emit) async {
+      await event.when(
+        started: () async {
+          final remindersOrFailure = await reminderService.fetchReminders();
+
+          remindersOrFailure.fold(
+            (error) => Log.error(error),
+            (reminders) => _updateState(emit, reminders),
+          );
+        },
+        remove: (reminderId) async {
+          final unitOrFailure =
+              await reminderService.removeReminder(reminderId: reminderId);
+
+          unitOrFailure.fold(
+            (error) => Log.error(error),
+            (_) {
+              final reminders = [...state.reminders];
+              reminders.removeWhere((e) => e.id == reminderId);
+              _updateState(emit, reminders);
+            },
+          );
+        },
+        add: (reminder) async {
+          final unitOrFailure =
+              await reminderService.addReminder(reminder: reminder);
+
+          return unitOrFailure.fold(
+            (error) => Log.error(error),
+            (_) {
+              state.reminders.add(reminder);
+              _updateState(emit, state.reminders);
+            },
+          );
+        },
+        update: (updateObject) async {
+          final reminder =
+              state.reminders.firstWhereOrNull((r) => r.id == updateObject.id);
+
+          if (reminder == null) {
+            return;
+          }
+
+          final newReminder = updateObject.merge(a: reminder);
+          final failureOrUnit = await reminderService.updateReminder(
+            reminder: updateObject.merge(a: reminder),
+          );
+
+          failureOrUnit.fold(
+            (error) => Log.error(error),
+            (_) {
+              final index =
+                  state.reminders.indexWhere((r) => r.id == reminder.id);
+              final reminders = [...state.reminders];
+              reminders.replaceRange(index, index + 1, [newReminder]);
+              _updateState(emit, reminders);
+            },
+          );
+        },
+        pressReminder: (reminderId) {
+          final reminder =
+              state.reminders.firstWhereOrNull((r) => r.id == reminderId);
+
+          if (reminder == null) {
+            return;
+          }
+
+          add(
+            ReminderEvent.update(ReminderUpdate(id: reminderId, isRead: true)),
+          );
+
+          actionBloc.add(
+            NotificationActionEvent.performAction(
+              action: NotificationAction(objectId: reminder.objectId),
+            ),
+          );
+        },
+      );
+    });
+  }
+
+  void _updateState(Emitter emit, List<ReminderPB> reminders) {
+    final now = DateTime.now();
+    final hasUnreads = reminders.any(
+      (r) =>
+          DateTime.fromMillisecondsSinceEpoch(r.scheduledAt.toInt() * 1000)
+              .isBefore(now) &&
+          !r.isRead,
+    );
+    emit(state.copyWith(reminders: reminders, hasUnreads: hasUnreads));
+  }
+
+  Timer _periodicCheck() {
+    return Timer.periodic(
+      const Duration(minutes: 1),
+      (_) {
+        final now = DateTime.now();
+        for (final reminder in state.reminders) {
+          if (reminder.isAck) {
+            continue;
+          }
+
+          final scheduledAt = DateTime.fromMillisecondsSinceEpoch(
+            reminder.scheduledAt.toInt() * 1000,
+          );
+
+          if (scheduledAt.isBefore(now)) {
+            NotificationMessage(
+              identifier: reminder.id,
+              title: LocaleKeys.reminderNotification_title.tr(),
+              body: LocaleKeys.reminderNotification_message.tr(),
+              onClick: () => actionBloc.add(
+                NotificationActionEvent.performAction(
+                  action: NotificationAction(objectId: reminder.objectId),
+                ),
+              ),
+            );
+
+            add(
+              ReminderEvent.update(
+                ReminderUpdate(id: reminder.id, isAck: true),
+              ),
+            );
+          }
+        }
+      },
+    );
+  }
+}
+
+@freezed
+class ReminderEvent with _$ReminderEvent {
+  // On startup we fetch all reminders and upcoming ones
+  const factory ReminderEvent.started() = _Started;
+
+  // Remove a reminder
+  const factory ReminderEvent.remove({required String reminderId}) = _Remove;
+
+  // Add a reminder
+  const factory ReminderEvent.add({required ReminderPB reminder}) = _Add;
+
+  // Update a reminder (eg. isAck, isRead, etc.)
+  const factory ReminderEvent.update(ReminderUpdate update) = _Update;
+
+  const factory ReminderEvent.pressReminder({required String reminderId}) =
+      _PressReminder;
+}
+
+/// Object used to merge updates with
+/// a [ReminderPB]
+///
+class ReminderUpdate {
+  final String id;
+  final bool? isAck;
+  final bool? isRead;
+  final DateTime? scheduledAt;
+
+  ReminderUpdate({
+    required this.id,
+    this.isAck,
+    this.isRead,
+    this.scheduledAt,
+  });
+
+  ReminderPB merge({required ReminderPB a}) {
+    final isAcknowledged = isAck == null && scheduledAt != null
+        ? scheduledAt!.isBefore(DateTime.now())
+        : a.isAck;
+
+    return ReminderPB(
+      id: a.id,
+      objectId: a.objectId,
+      scheduledAt: scheduledAt != null
+          ? Int64(scheduledAt!.millisecondsSinceEpoch ~/ 1000)
+          : a.scheduledAt,
+      isAck: isAcknowledged,
+      isRead: isRead ?? a.isRead,
+      title: a.title,
+      message: a.message,
+      meta: a.meta,
+    );
+  }
+}
+
+class ReminderState {
+  ReminderState({
+    List<ReminderPB>? reminders,
+    bool? hasUnreads,
+  })  : reminders = reminders ?? [],
+        hasUnreads = hasUnreads ?? false;
+
+  final List<ReminderPB> reminders;
+  final bool hasUnreads;
+
+  ReminderState copyWith({
+    List<ReminderPB>? reminders,
+    bool? hasUnreads,
+  }) =>
+      ReminderState(
+        reminders: reminders ?? this.reminders,
+        hasUnreads: hasUnreads ?? this.hasUnreads,
+      );
+}

+ 58 - 0
frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart

@@ -0,0 +1,58 @@
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
+import 'package:dartz/dartz.dart';
+
+/// Interface for a Reminder Service that handles
+/// communication to the backend
+///
+abstract class IReminderService {
+  Future<Either<FlowyError, List<ReminderPB>>> fetchReminders();
+
+  Future<Either<FlowyError, Unit>> removeReminder({required String reminderId});
+
+  Future<Either<FlowyError, Unit>> addReminder({required ReminderPB reminder});
+
+  Future<Either<FlowyError, Unit>> updateReminder({
+    required ReminderPB reminder,
+  });
+}
+
+class ReminderService implements IReminderService {
+  const ReminderService();
+
+  @override
+  Future<Either<FlowyError, Unit>> addReminder({
+    required ReminderPB reminder,
+  }) async {
+    final unitOrFailure = await UserEventCreateReminder(reminder).send();
+
+    return unitOrFailure.swap();
+  }
+
+  @override
+  Future<Either<FlowyError, Unit>> updateReminder({
+    required ReminderPB reminder,
+  }) async {
+    final unitOrFailure = await UserEventUpdateReminder(reminder).send();
+
+    return unitOrFailure.swap();
+  }
+
+  @override
+  Future<Either<FlowyError, List<ReminderPB>>> fetchReminders() async {
+    final resultOrFailure = await UserEventGetAllReminders().send();
+
+    return resultOrFailure.swap().fold((l) => left(l), (r) => right(r.items));
+  }
+
+  @override
+  Future<Either<FlowyError, Unit>> removeReminder({
+    required String reminderId,
+  }) async {
+    final request = ReminderIdentifierPB(id: reminderId);
+    final unitOrFailure = await UserEventRemoveReminder(request).send();
+
+    return unitOrFailure.swap();
+  }
+}

+ 19 - 6
frontend/appflowy_flutter/lib/user/application/user_settings_service.dart

@@ -10,12 +10,9 @@ class UserSettingsBackendService {
     final result = await UserEventGetAppearanceSetting().send();
 
     return result.fold(
-      (AppearanceSettingsPB setting) {
-        return setting;
-      },
-      (error) {
-        throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty);
-      },
+      (AppearanceSettingsPB setting) => setting,
+      (error) =>
+          throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
     );
   }
 
@@ -28,4 +25,20 @@ class UserSettingsBackendService {
   ) {
     return UserEventSetAppearanceSetting(setting).send();
   }
+
+  Future<DateTimeSettingsPB> getDateTimeSettings() async {
+    final result = await UserEventGetDateTimeSettings().send();
+
+    return result.fold(
+      (DateTimeSettingsPB setting) => setting,
+      (error) =>
+          throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
+    );
+  }
+
+  Future<Either<FlowyError, Unit>> setDateTimeSettings(
+    DateTimeSettingsPB settings,
+  ) async {
+    return (await UserEventSetDateTimeSettings(settings).send()).swap();
+  }
 }

+ 39 - 0
frontend/appflowy_flutter/lib/workspace/application/appearance.dart

@@ -5,6 +5,7 @@ import 'package:appflowy/util/platform_extension.dart';
 import 'package:appflowy/workspace/application/appearance_defaults.dart';
 import 'package:appflowy/mobile/application/mobile_theme_data.dart';
 import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/size.dart';
@@ -23,11 +24,14 @@ const _white = Color(0xFFFFFFFF);
 /// It includes the [AppTheme], [ThemeMode], [TextStyles] and [Locale].
 class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
   final AppearanceSettingsPB _setting;
+  final DateTimeSettingsPB _dateTimeSettings;
 
   AppearanceSettingsCubit(
     AppearanceSettingsPB setting,
+    DateTimeSettingsPB dateTimeSettings,
     AppTheme appTheme,
   )   : _setting = setting,
+        _dateTimeSettings = dateTimeSettings,
         super(
           AppearanceSettingsState.initial(
             appTheme,
@@ -39,6 +43,9 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
             setting.locale,
             setting.isMenuCollapsed,
             setting.menuOffset,
+            dateTimeSettings.dateFormat,
+            dateTimeSettings.timeFormat,
+            dateTimeSettings.timezoneId,
           ),
         );
 
@@ -173,6 +180,29 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
     setLocale(context, state.locale);
   }
 
+  void setDateFormat(UserDateFormatPB format) {
+    _dateTimeSettings.dateFormat = format;
+    _saveDateTimeSettings();
+    emit(state.copyWith(dateFormat: format));
+  }
+
+  void setTimeFormat(UserTimeFormatPB format) {
+    _dateTimeSettings.timeFormat = format;
+    _saveDateTimeSettings();
+    emit(state.copyWith(timeFormat: format));
+  }
+
+  Future<void> _saveDateTimeSettings() async {
+    UserSettingsBackendService()
+        .setDateTimeSettings(_dateTimeSettings)
+        .then((result) {
+      result.fold(
+        (error) => Log.error(error),
+        (_) => null,
+      );
+    });
+  }
+
   Future<void> _saveAppearanceSettings() async {
     UserSettingsBackendService().setAppearanceSetting(_setting).then((result) {
       result.fold(
@@ -271,6 +301,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
     required Locale locale,
     required bool isMenuCollapsed,
     required double menuOffset,
+    required UserDateFormatPB dateFormat,
+    required UserTimeFormatPB timeFormat,
+    required String timezoneId,
   }) = _AppearanceSettingsState;
 
   factory AppearanceSettingsState.initial(
@@ -283,6 +316,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
     LocaleSettingsPB localePB,
     bool isMenuCollapsed,
     double menuOffset,
+    UserDateFormatPB dateFormat,
+    UserTimeFormatPB timeFormat,
+    String timezoneId,
   ) {
     return AppearanceSettingsState(
       appTheme: appTheme,
@@ -294,6 +330,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
       locale: Locale(localePB.languageCode, localePB.countryCode),
       isMenuCollapsed: isMenuCollapsed,
       menuOffset: menuOffset,
+      dateFormat: dateFormat,
+      timeFormat: timeFormat,
+      timezoneId: timezoneId,
     );
   }
 

+ 19 - 0
frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action.dart

@@ -0,0 +1,19 @@
+enum ActionType {
+  openView,
+}
+
+/// A [NotificationAction] is used to communicate with the
+/// [NotificationActionBloc] to perform actions based on an event
+/// triggered by pressing a notification, such as opening a specific
+/// view and jumping to a specific block.
+///
+class NotificationAction {
+  const NotificationAction({
+    this.type = ActionType.openView,
+    required this.objectId,
+  });
+
+  final ActionType type;
+
+  final String objectId;
+}

+ 38 - 0
frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action_bloc.dart

@@ -0,0 +1,38 @@
+import 'package:appflowy/workspace/application/local_notifications/notification_action.dart';
+import 'package:bloc/bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'notification_action_bloc.freezed.dart';
+
+class NotificationActionBloc
+    extends Bloc<NotificationActionEvent, NotificationActionState> {
+  NotificationActionBloc() : super(const NotificationActionState.initial()) {
+    on<NotificationActionEvent>((event, emit) async {
+      event.when(
+        performAction: (action) {
+          emit(state.copyWith(action: action));
+        },
+      );
+    });
+  }
+}
+
+@freezed
+class NotificationActionEvent with _$NotificationActionEvent {
+  const factory NotificationActionEvent.performAction({
+    required NotificationAction action,
+  }) = _PerformAction;
+}
+
+class NotificationActionState {
+  const NotificationActionState({required this.action});
+
+  final NotificationAction? action;
+
+  const NotificationActionState.initial() : action = null;
+
+  NotificationActionState copyWith({
+    NotificationAction? action,
+  }) =>
+      NotificationActionState(action: action ?? this.action);
+}

+ 43 - 0
frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_service.dart

@@ -0,0 +1,43 @@
+import 'package:flutter/foundation.dart';
+import 'package:local_notifier/local_notifier.dart';
+
+const _appName = "AppFlowy";
+
+/// Manages Local Notifications
+///
+/// Currently supports:
+///  - MacOS
+///  - Windows
+///  - Linux
+///
+class NotificationService {
+  static Future<void> initialize() async {
+    await localNotifier.setup(
+      appName: _appName,
+      shortcutPolicy: ShortcutPolicy.requireCreate, // Windows Specific
+    );
+  }
+}
+
+/// Creates and shows a Notification
+///
+class NotificationMessage {
+  NotificationMessage({
+    required String title,
+    required String body,
+    String? identifier,
+    VoidCallback? onClick,
+  }) {
+    _notification = LocalNotification(
+      identifier: identifier,
+      title: title,
+      body: body,
+    )..onClick = onClick;
+
+    _show();
+  }
+
+  late final LocalNotification _notification;
+
+  void _show() => _notification.show();
+}

+ 41 - 0
frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart

@@ -0,0 +1,41 @@
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:easy_localization/easy_localization.dart';
+
+const _localFmt = 'M/d/y';
+const _usFmt = 'y/M/d';
+const _isoFmt = 'ymd';
+const _friendlyFmt = 'MMM d, y';
+const _dmyFmt = 'd/M/y';
+
+extension DateFormatter on UserDateFormatPB {
+  DateFormat get toFormat => DateFormat(_toFormat[this] ?? _friendlyFmt);
+
+  String formatDate(
+    DateTime date,
+    bool includeTime, [
+    UserTimeFormatPB? timeFormat,
+  ]) {
+    final format = toFormat;
+
+    if (includeTime) {
+      switch (timeFormat) {
+        case UserTimeFormatPB.TwentyFourHour:
+          return format.add_Hm().format(date);
+        case UserTimeFormatPB.TwelveHour:
+          return format.add_jm().format(date);
+        default:
+          return format.format(date);
+      }
+    }
+
+    return format.format(date);
+  }
+}
+
+final _toFormat = {
+  UserDateFormatPB.Locally: _localFmt,
+  UserDateFormatPB.US: _usFmt,
+  UserDateFormatPB.ISO: _isoFmt,
+  UserDateFormatPB.Friendly: _friendlyFmt,
+  UserDateFormatPB.DayMonthYear: _dmyFmt,
+};

+ 18 - 0
frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart

@@ -0,0 +1,18 @@
+/// RegExp to match Twelve Hour formats
+/// Source: https://stackoverflow.com/a/33906224
+///
+/// Matches eg: "05:05 PM", "5:50 Pm", "10:59 am", etc.
+///
+final _twelveHourTimePattern =
+    RegExp(r'\b((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))');
+bool isTwelveHourTime(String? time) =>
+    _twelveHourTimePattern.hasMatch(time ?? '');
+
+/// RegExp to match Twenty Four Hour formats
+/// Source: https://stackoverflow.com/a/7536768
+///
+/// Matches eg: "0:01", "04:59", "16:30", etc.
+///
+final _twentyFourHourtimePattern = RegExp(r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');
+bool isTwentyFourHourTime(String? time) =>
+    _twentyFourHourtimePattern.hasMatch(time ?? '');

+ 4 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart

@@ -2,6 +2,7 @@ import 'package:appflowy/plugins/blank/blank.dart';
 import 'package:appflowy/startup/plugin/plugin.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/user/application/auth/auth_service.dart';
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
 import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:appflowy/workspace/application/home/home_bloc.dart';
 import 'package:appflowy/workspace/application/home/home_service.dart';
@@ -57,6 +58,9 @@ class DesktopHomeScreen extends StatelessWidget {
         return MultiBlocProvider(
           key: ValueKey(userProfile!.id),
           providers: [
+            BlocProvider<ReminderBloc>.value(
+              value: getIt<ReminderBloc>()..add(const ReminderEvent.started()),
+            ),
             BlocProvider<TabsBloc>.value(value: getIt<TabsBloc>()),
             BlocProvider<HomeBloc>(
               create: (context) {

+ 36 - 6
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart

@@ -1,4 +1,7 @@
+import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_action.dart';
+import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
 import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
 import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart';
@@ -10,6 +13,7 @@ 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';
@@ -36,6 +40,9 @@ class HomeSideBar extends StatelessWidget {
   Widget build(BuildContext context) {
     return MultiBlocProvider(
       providers: [
+        BlocProvider(
+          create: (_) => getIt<NotificationActionBloc>(),
+        ),
         BlocProvider(
           create: (_) => MenuBloc(
             user: user,
@@ -46,11 +53,34 @@ class HomeSideBar extends StatelessWidget {
           create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
         )
       ],
-      child: BlocListener<MenuBloc, MenuState>(
-        listenWhen: (p, c) => p.plugin.id != c.plugin.id,
-        listener: (context, state) => context
-            .read<TabsBloc>()
-            .add(TabsEvent.openPlugin(plugin: state.plugin)),
+      child: MultiBlocListener(
+        listeners: [
+          BlocListener<MenuBloc, MenuState>(
+            listenWhen: (p, c) => p.plugin.id != c.plugin.id,
+            listener: (context, state) => context
+                .read<TabsBloc>()
+                .add(TabsEvent.openPlugin(plugin: state.plugin)),
+          ),
+          BlocListener<NotificationActionBloc, NotificationActionState>(
+            listener: (context, state) {
+              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(
           builder: (context) {
             final menuState = context.watch<MenuBloc>().state;
@@ -88,7 +118,7 @@ class HomeSideBar extends StatelessWidget {
             // top menu
             const SidebarTopMenu(),
             // user, setting
-            SidebarUser(user: user),
+            SidebarUser(user: user, views: views),
             const VSpace(20),
             // scrollable document list
             Expanded(

+ 6 - 0
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart

@@ -2,7 +2,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
 import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart';
+import 'package:appflowy/workspace/presentation/notifications/notification_button.dart';
 import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
 import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flowy_infra_ui/widget/spacing.dart';
@@ -17,9 +19,11 @@ class SidebarUser extends StatelessWidget {
   const SidebarUser({
     super.key,
     required this.user,
+    required this.views,
   });
 
   final UserProfilePB user;
+  final List<ViewPB> views;
 
   @override
   Widget build(BuildContext context) {
@@ -41,6 +45,8 @@ class SidebarUser extends StatelessWidget {
               child: _buildUserName(context, state),
             ),
             _buildSettingsButton(context, state),
+            const HSpace(4),
+            NotificationButton(views: views),
           ],
         ),
       ),

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

@@ -10,7 +10,7 @@ class FlowyMessageToast extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return Container(
+    return DecoratedBox(
       decoration: BoxDecoration(
         borderRadius: const BorderRadius.all(Radius.circular(4)),
         color: Theme.of(context).colorScheme.surface,

+ 67 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart

@@ -0,0 +1,67 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
+import 'package:appflowy/workspace/presentation/notifications/notification_dialog.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class NotificationButton extends StatelessWidget {
+  const NotificationButton({super.key, required this.views});
+
+  final List<ViewPB> views;
+
+  @override
+  Widget build(BuildContext context) {
+    final mutex = PopoverMutex();
+
+    return BlocProvider<ReminderBloc>.value(
+      value: getIt<ReminderBloc>(),
+      child: BlocBuilder<ReminderBloc, ReminderState>(
+        builder: (context, state) => Tooltip(
+          message: LocaleKeys.notificationHub_title.tr(),
+          child: MouseRegion(
+            cursor: SystemMouseCursors.click,
+            child: AppFlowyPopover(
+              mutex: mutex,
+              direction: PopoverDirection.bottomWithLeftAligned,
+              constraints: const BoxConstraints(maxHeight: 250, maxWidth: 300),
+              popupBuilder: (_) =>
+                  NotificationDialog(views: views, mutex: mutex),
+              child: _buildNotificationIcon(context, state.hasUnreads),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildNotificationIcon(BuildContext context, bool hasUnreads) {
+    return Stack(
+      children: [
+        FlowySvg(
+          FlowySvgs.clock_alarm_s,
+          size: const Size.square(24),
+          color: Theme.of(context).colorScheme.tertiary,
+        ),
+        if (hasUnreads)
+          Positioned(
+            bottom: 2,
+            right: 2,
+            child: DecoratedBox(
+              decoration: BoxDecoration(
+                shape: BoxShape.circle,
+                color: AFThemeExtension.of(context).warning,
+              ),
+              child: const SizedBox(height: 8, width: 8),
+            ),
+          ),
+      ],
+    );
+  }
+}

+ 123 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart

@@ -0,0 +1,123 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
+import 'package:appflowy/workspace/presentation/notifications/notification_item.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+extension _ReminderReady on ReminderPB {
+  DateTime get scheduledDate =>
+      DateTime.fromMillisecondsSinceEpoch(scheduledAt.toInt() * 1000);
+
+  bool isBefore(DateTime date) => scheduledDate.isBefore(date);
+}
+
+class NotificationDialog extends StatelessWidget {
+  const NotificationDialog({
+    super.key,
+    required this.views,
+    required this.mutex,
+  });
+
+  final List<ViewPB> views;
+  final PopoverMutex mutex;
+
+  @override
+  Widget build(BuildContext context) {
+    final reminderBloc = getIt<ReminderBloc>();
+
+    return BlocProvider<ReminderBloc>.value(
+      value: reminderBloc,
+      child: BlocBuilder<ReminderBloc, ReminderState>(
+        builder: (context, state) {
+          final shownReminders = state.reminders
+              .where((reminder) => reminder.isBefore(DateTime.now()))
+              .sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt));
+
+          return SingleChildScrollView(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Row(
+                  children: [
+                    Expanded(
+                      child: DecoratedBox(
+                        decoration: BoxDecoration(
+                          border: Border(
+                            bottom: BorderSide(
+                              color: Theme.of(context).dividerColor,
+                            ),
+                          ),
+                        ),
+                        child: Padding(
+                          padding: const EdgeInsets.symmetric(
+                            vertical: 4,
+                            horizontal: 10,
+                          ),
+                          child: FlowyText.semibold(
+                            LocaleKeys.notificationHub_title.tr(),
+                            fontSize: 16,
+                          ),
+                        ),
+                      ),
+                    ),
+                  ],
+                ),
+                const VSpace(4),
+                if (shownReminders.isEmpty)
+                  Padding(
+                    padding: const EdgeInsets.symmetric(vertical: 12),
+                    child: Center(
+                      child: FlowyText.regular(
+                        LocaleKeys.notificationHub_empty.tr(),
+                      ),
+                    ),
+                  )
+                else
+                  ...shownReminders.map((reminder) {
+                    return NotificationItem(
+                      reminderId: reminder.id,
+                      key: ValueKey(reminder.id),
+                      title: reminder.title,
+                      scheduled: reminder.scheduledAt,
+                      body: reminder.message,
+                      isRead: reminder.isRead,
+                      onReadChanged: (isRead) => reminderBloc.add(
+                        ReminderEvent.update(
+                          ReminderUpdate(id: reminder.id, isRead: isRead),
+                        ),
+                      ),
+                      onDelete: () => reminderBloc
+                          .add(ReminderEvent.remove(reminderId: reminder.id)),
+                      onAction: () {
+                        final view = views.firstWhereOrNull(
+                          (view) => view.id == reminder.objectId,
+                        );
+
+                        if (view == null) {
+                          return;
+                        }
+
+                        reminderBloc.add(
+                          ReminderEvent.pressReminder(reminderId: reminder.id),
+                        );
+
+                        mutex.close();
+                      },
+                    );
+                  }),
+              ],
+            ),
+          );
+        },
+      ),
+    );
+  }
+}

+ 195 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart

@@ -0,0 +1,195 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:fixnum/fixnum.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+
+DateFormat _dateFormat(BuildContext context) => DateFormat('MMM d, y');
+
+class NotificationItem extends StatefulWidget {
+  const NotificationItem({
+    super.key,
+    required this.reminderId,
+    required this.title,
+    required this.scheduled,
+    required this.body,
+    required this.isRead,
+    this.onAction,
+    this.onDelete,
+    this.onReadChanged,
+  });
+
+  final String reminderId;
+  final String title;
+  final Int64 scheduled;
+  final String body;
+  final bool isRead;
+
+  final VoidCallback? onAction;
+  final VoidCallback? onDelete;
+  final void Function(bool isRead)? onReadChanged;
+
+  @override
+  State<NotificationItem> createState() => _NotificationItemState();
+}
+
+class _NotificationItemState extends State<NotificationItem> {
+  final PopoverMutex mutex = PopoverMutex();
+  bool _isHovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      onEnter: (_) => _onHover(true),
+      onExit: (_) => _onHover(false),
+      cursor: widget.onAction != null
+          ? SystemMouseCursors.click
+          : MouseCursor.defer,
+      child: Stack(
+        children: [
+          GestureDetector(
+            onTap: widget.onAction,
+            child: Opacity(
+              opacity: widget.isRead ? 0.5 : 1,
+              child: Container(
+                padding: const EdgeInsets.all(10),
+                decoration: BoxDecoration(
+                  borderRadius: const BorderRadius.all(Radius.circular(6)),
+                  color: _isHovering && widget.onAction != null
+                      ? AFThemeExtension.of(context).lightGreyHover
+                      : Colors.transparent,
+                ),
+                child: Row(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Stack(
+                      children: [
+                        const FlowySvg(FlowySvgs.time_s, size: Size.square(20)),
+                        if (!widget.isRead)
+                          Positioned(
+                            bottom: 1,
+                            right: 1,
+                            child: DecoratedBox(
+                              decoration: BoxDecoration(
+                                shape: BoxShape.circle,
+                                color: AFThemeExtension.of(context).warning,
+                              ),
+                              child: const SizedBox(height: 8, width: 8),
+                            ),
+                          ),
+                      ],
+                    ),
+                    const HSpace(10),
+                    Expanded(
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        mainAxisAlignment: MainAxisAlignment.center,
+                        children: [
+                          Row(
+                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                            children: [
+                              Flexible(
+                                child: FlowyText.semibold(widget.title),
+                              ),
+                              FlowyText.regular(
+                                _scheduledString(widget.scheduled),
+                                fontSize: 10,
+                              ),
+                            ],
+                          ),
+                          const VSpace(5),
+                          FlowyText.regular(widget.body, maxLines: 4),
+                        ],
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+          if (_isHovering)
+            Positioned(
+              right: 4,
+              top: 4,
+              child: NotificationItemActions(
+                isRead: widget.isRead,
+                onDelete: widget.onDelete,
+                onReadChanged: widget.onReadChanged,
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+
+  String _scheduledString(Int64 secondsSinceEpoch) =>
+      _dateFormat(context).format(
+        DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch.toInt() * 1000),
+      );
+
+  void _onHover(bool isHovering) => setState(() => _isHovering = isHovering);
+}
+
+class NotificationItemActions extends StatelessWidget {
+  const NotificationItemActions({
+    super.key,
+    required this.isRead,
+    this.onDelete,
+    this.onReadChanged,
+  });
+
+  final bool isRead;
+  final VoidCallback? onDelete;
+  final void Function(bool isRead)? onReadChanged;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      height: 30,
+      decoration: BoxDecoration(
+        color: Theme.of(context).cardColor,
+        border: Border.all(color: Theme.of(context).dividerColor),
+        borderRadius: BorderRadius.circular(6),
+      ),
+      child: IntrinsicHeight(
+        child: Row(
+          children: [
+            if (isRead) ...[
+              FlowyIconButton(
+                height: 28,
+                tooltipText:
+                    LocaleKeys.reminderNotification_tooltipMarkUnread.tr(),
+                icon: const FlowySvg(FlowySvgs.restore_s),
+                onPressed: () => onReadChanged?.call(false),
+              ),
+            ] else ...[
+              FlowyIconButton(
+                height: 28,
+                tooltipText:
+                    LocaleKeys.reminderNotification_tooltipMarkRead.tr(),
+                icon: const FlowySvg(FlowySvgs.messages_s),
+                onPressed: () => onReadChanged?.call(true),
+              ),
+            ],
+            VerticalDivider(
+              width: 3,
+              thickness: 1,
+              indent: 2,
+              endIndent: 2,
+              color: Theme.of(context).dividerColor,
+            ),
+            FlowyIconButton(
+              height: 28,
+              tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(),
+              icon: const FlowySvg(FlowySvgs.delete_s),
+              onPressed: onDelete,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 72 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart

@@ -0,0 +1,72 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/application/appearance.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import 'theme_setting_entry_template.dart';
+
+class DateFormatSetting extends StatelessWidget {
+  const DateFormatSetting({
+    super.key,
+    required this.currentFormat,
+  });
+
+  final UserDateFormatPB currentFormat;
+
+  @override
+  Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget(
+        label: LocaleKeys.settings_appearance_dateFormat_label.tr(),
+        trailing: [
+          ThemeValueDropDown(
+            currentValue: _formatLabel(currentFormat),
+            popupBuilder: (_) => Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                _formatItem(context, UserDateFormatPB.Locally),
+                _formatItem(context, UserDateFormatPB.US),
+                _formatItem(context, UserDateFormatPB.ISO),
+                _formatItem(context, UserDateFormatPB.Friendly),
+                _formatItem(context, UserDateFormatPB.DayMonthYear),
+              ],
+            ),
+          ),
+        ],
+      );
+
+  Widget _formatItem(BuildContext context, UserDateFormatPB format) {
+    return SizedBox(
+      height: 32,
+      child: FlowyButton(
+        text: FlowyText.medium(_formatLabel(format)),
+        rightIcon:
+            currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null,
+        onTap: () {
+          if (currentFormat != format) {
+            context.read<AppearanceSettingsCubit>().setDateFormat(format);
+          }
+        },
+      ),
+    );
+  }
+
+  String _formatLabel(UserDateFormatPB format) {
+    switch (format) {
+      case (UserDateFormatPB.Locally):
+        return LocaleKeys.settings_appearance_dateFormat_local.tr();
+      case (UserDateFormatPB.US):
+        return LocaleKeys.settings_appearance_dateFormat_us.tr();
+      case (UserDateFormatPB.ISO):
+        return LocaleKeys.settings_appearance_dateFormat_iso.tr();
+      case (UserDateFormatPB.Friendly):
+        return LocaleKeys.settings_appearance_dateFormat_friendly.tr();
+      case (UserDateFormatPB.DayMonthYear):
+        return LocaleKeys.settings_appearance_dateFormat_dmy.tr();
+      default:
+        return "";
+    }
+  }
+}

+ 63 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart

@@ -0,0 +1,63 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/application/appearance.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import 'theme_setting_entry_template.dart';
+
+class TimeFormatSetting extends StatelessWidget {
+  const TimeFormatSetting({
+    super.key,
+    required this.currentFormat,
+  });
+
+  final UserTimeFormatPB currentFormat;
+
+  @override
+  Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget(
+        label: LocaleKeys.settings_appearance_timeFormat_label.tr(),
+        trailing: [
+          ThemeValueDropDown(
+            currentValue: _formatLabel(currentFormat),
+            popupBuilder: (_) => Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                _formatItem(context, UserTimeFormatPB.TwentyFourHour),
+                _formatItem(context, UserTimeFormatPB.TwelveHour),
+              ],
+            ),
+          ),
+        ],
+      );
+
+  Widget _formatItem(BuildContext context, UserTimeFormatPB format) {
+    return SizedBox(
+      height: 32,
+      child: FlowyButton(
+        text: FlowyText.medium(_formatLabel(format)),
+        rightIcon:
+            currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null,
+        onTap: () {
+          if (currentFormat != format) {
+            context.read<AppearanceSettingsCubit>().setTimeFormat(format);
+          }
+        },
+      ),
+    );
+  }
+
+  String _formatLabel(UserTimeFormatPB format) {
+    switch (format) {
+      case (UserTimeFormatPB.TwentyFourHour):
+        return LocaleKeys.settings_appearance_timeFormat_twentyFourHour.tr();
+      case (UserTimeFormatPB.TwelveHour):
+        return LocaleKeys.settings_appearance_timeFormat_twelveHour.tr();
+      default:
+        return "";
+    }
+  }
+}

+ 8 - 9
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart

@@ -1,5 +1,7 @@
 import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart';
 import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -23,20 +25,17 @@ class SettingsAppearanceView extends StatelessWidget {
                   currentTheme: state.appTheme.themeName,
                   bloc: context.read<DynamicPluginBloc>(),
                 ),
-                BrightnessSetting(
-                  currentThemeMode: state.themeMode,
-                ),
+                BrightnessSetting(currentThemeMode: state.themeMode),
                 const Divider(),
-                ThemeFontFamilySetting(
-                  currentFontFamily: state.font,
-                ),
+                ThemeFontFamilySetting(currentFontFamily: state.font),
                 const Divider(),
                 LayoutDirectionSetting(
                   currentLayoutDirection: state.layoutDirection,
                 ),
-                TextDirectionSetting(
-                  currentTextDirection: state.textDirection,
-                ),
+                TextDirectionSetting(currentTextDirection: state.textDirection),
+                const Divider(),
+                DateFormatSetting(currentFormat: state.dateFormat),
+                TimeFormatSetting(currentFormat: state.timeFormat),
                 const Divider(),
                 CreateFileSettings(),
               ],

+ 277 - 0
frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_calendar.dart

@@ -0,0 +1,277 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
+import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart';
+import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:flutter/material.dart';
+import 'package:table_calendar/table_calendar.dart';
+
+final kFirstDay = DateTime.utc(1970, 1, 1);
+final kLastDay = DateTime.utc(2100, 1, 1);
+
+typedef DaySelectedCallback = void Function(
+  DateTime selectedDay,
+  DateTime focusedDay,
+  bool includeTime,
+);
+typedef IncludeTimeChangedCallback = void Function(bool includeTime);
+typedef FormatChangedCallback = void Function(CalendarFormat format);
+typedef PageChangedCallback = void Function(DateTime focusedDay);
+typedef TimeChangedCallback = void Function(String? time);
+
+class AppFlowyCalendar extends StatefulWidget {
+  const AppFlowyCalendar({
+    super.key,
+    this.popoverMutex,
+    this.firstDay,
+    this.lastDay,
+    this.selectedDate,
+    required this.focusedDay,
+    this.format = CalendarFormat.month,
+    this.onDaySelected,
+    this.onFormatChanged,
+    this.onPageChanged,
+    this.onIncludeTimeChanged,
+    this.onTimeChanged,
+    this.includeTime = false,
+    this.timeFormat = UserTimeFormatPB.TwentyFourHour,
+  });
+
+  final PopoverMutex? popoverMutex;
+
+  /// Disallows choosing dates before this date
+  final DateTime? firstDay;
+
+  /// Disallows choosing dates after this date
+  final DateTime? lastDay;
+
+  final DateTime? selectedDate;
+  final DateTime focusedDay;
+  final CalendarFormat format;
+
+  final DaySelectedCallback? onDaySelected;
+  final IncludeTimeChangedCallback? onIncludeTimeChanged;
+  final FormatChangedCallback? onFormatChanged;
+  final PageChangedCallback? onPageChanged;
+  final TimeChangedCallback? onTimeChanged;
+
+  final bool includeTime;
+
+  // Timeformat for time selector
+  final UserTimeFormatPB timeFormat;
+
+  @override
+  State<AppFlowyCalendar> createState() => _AppFlowyCalendarState();
+}
+
+class _AppFlowyCalendarState extends State<AppFlowyCalendar>
+    with AutomaticKeepAliveClientMixin {
+  String? _time;
+
+  late DateTime? _selectedDay = widget.selectedDate;
+  late DateTime _focusedDay = widget.focusedDay;
+  late bool _includeTime = widget.includeTime;
+
+  @override
+  void initState() {
+    super.initState();
+    if (widget.includeTime) {
+      final hour = widget.focusedDay.hour;
+      final minute = widget.focusedDay.minute;
+      _time = '$hour:$minute';
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    super.build(context);
+
+    final textStyle = Theme.of(context).textTheme.bodyMedium!;
+    final boxDecoration = BoxDecoration(
+      color: Theme.of(context).cardColor,
+      shape: BoxShape.circle,
+    );
+
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        const VSpace(18),
+        Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 12.0),
+          child: TableCalendar(
+            currentDay: DateTime.now(),
+            firstDay: widget.firstDay ?? kFirstDay,
+            lastDay: widget.lastDay ?? kLastDay,
+            focusedDay: _focusedDay,
+            rowHeight: GridSize.popoverItemHeight,
+            calendarFormat: widget.format,
+            daysOfWeekHeight: GridSize.popoverItemHeight,
+            headerStyle: HeaderStyle(
+              formatButtonVisible: false,
+              titleCentered: true,
+              titleTextStyle: textStyle,
+              leftChevronMargin: EdgeInsets.zero,
+              leftChevronPadding: EdgeInsets.zero,
+              leftChevronIcon: FlowySvg(
+                FlowySvgs.arrow_left_s,
+                color: Theme.of(context).iconTheme.color,
+              ),
+              rightChevronPadding: EdgeInsets.zero,
+              rightChevronMargin: EdgeInsets.zero,
+              rightChevronIcon: FlowySvg(
+                FlowySvgs.arrow_right_s,
+                color: Theme.of(context).iconTheme.color,
+              ),
+              headerMargin: EdgeInsets.zero,
+              headerPadding: const EdgeInsets.only(bottom: 8.0),
+            ),
+            calendarStyle: CalendarStyle(
+              cellMargin: const EdgeInsets.all(3.5),
+              defaultDecoration: boxDecoration,
+              selectedDecoration: boxDecoration.copyWith(
+                color: Theme.of(context).colorScheme.primary,
+              ),
+              todayDecoration: boxDecoration.copyWith(
+                color: Colors.transparent,
+                border: Border.all(
+                  color: Theme.of(context).colorScheme.primary,
+                ),
+              ),
+              weekendDecoration: boxDecoration,
+              outsideDecoration: boxDecoration,
+              rangeStartDecoration: boxDecoration.copyWith(
+                color: Theme.of(context).colorScheme.primary,
+              ),
+              rangeEndDecoration: boxDecoration.copyWith(
+                color: Theme.of(context).colorScheme.primary,
+              ),
+              defaultTextStyle: textStyle,
+              weekendTextStyle: textStyle,
+              selectedTextStyle: textStyle.copyWith(
+                color: Theme.of(context).colorScheme.surface,
+              ),
+              rangeStartTextStyle: textStyle.copyWith(
+                color: Theme.of(context).colorScheme.surface,
+              ),
+              rangeEndTextStyle: textStyle.copyWith(
+                color: Theme.of(context).colorScheme.surface,
+              ),
+              todayTextStyle: textStyle,
+              outsideTextStyle: textStyle.copyWith(
+                color: Theme.of(context).disabledColor,
+              ),
+              rangeHighlightColor:
+                  Theme.of(context).colorScheme.secondaryContainer,
+            ),
+            calendarBuilders: CalendarBuilders(
+              dowBuilder: (context, day) {
+                final locale = context.locale.toLanguageTag();
+                final label = DateFormat.E(locale).format(day).substring(0, 2);
+
+                return Padding(
+                  padding: const EdgeInsets.only(bottom: 8.0),
+                  child: Center(
+                    child: Text(
+                      label,
+                      style: AFThemeExtension.of(context).caption,
+                    ),
+                  ),
+                );
+              },
+            ),
+            selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
+            onDaySelected: (selectedDay, focusedDay) {
+              if (!_includeTime) {
+                widget.onDaySelected?.call(
+                  selectedDay,
+                  focusedDay,
+                  _includeTime,
+                );
+              }
+
+              setState(() {
+                _selectedDay = selectedDay;
+                _focusedDay = focusedDay;
+              });
+
+              _updateSelectedDay(selectedDay, focusedDay, _includeTime);
+            },
+            onFormatChanged: widget.onFormatChanged,
+            onPageChanged: widget.onPageChanged,
+          ),
+        ),
+        const TypeOptionSeparator(spacing: 12.0),
+        IncludeTimeButton(
+          initialTime: widget.selectedDate != null
+              ? _initialTime(widget.selectedDate!)
+              : null,
+          includeTime: widget.includeTime,
+          timeFormat: widget.timeFormat,
+          popoverMutex: widget.popoverMutex,
+          onChanged: (includeTime) {
+            setState(() => _includeTime = includeTime);
+
+            widget.onIncludeTimeChanged?.call(includeTime);
+          },
+          onSubmitted: (time) {
+            _time = time;
+
+            if (widget.selectedDate != null && widget.onTimeChanged == null) {
+              _updateSelectedDay(
+                widget.selectedDate!,
+                widget.selectedDate!,
+                _includeTime,
+              );
+            }
+
+            widget.onTimeChanged?.call(time);
+          },
+        ),
+        const VSpace(6.0),
+      ],
+    );
+  }
+
+  DateTime _dateWithTime(DateTime date, DateTime time) {
+    return DateTime.parse(
+      '${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(time.hour)}:${_padZeroLeft(time.minute)}',
+    );
+  }
+
+  String _initialTime(DateTime selectedDay) => switch (widget.timeFormat) {
+        UserTimeFormatPB.TwelveHour => DateFormat.jm().format(selectedDay),
+        UserTimeFormatPB.TwentyFourHour => DateFormat.Hm().format(selectedDay),
+        _ => '00:00',
+      };
+
+  String _padZeroLeft(int a) => a.toString().padLeft(2, '0');
+
+  void _updateSelectedDay(
+    DateTime selectedDay,
+    DateTime focusedDay,
+    bool includeTime,
+  ) {
+    late DateTime timeOfDay;
+    switch (widget.timeFormat) {
+      case UserTimeFormatPB.TwelveHour:
+        timeOfDay = DateFormat.jm().parse(_time ?? '12:00 AM');
+        break;
+      case UserTimeFormatPB.TwentyFourHour:
+        timeOfDay = DateFormat.Hm().parse(_time ?? '00:00');
+        break;
+    }
+
+    widget.onDaySelected?.call(
+      _dateWithTime(selectedDay, timeOfDay),
+      focusedDay,
+      _includeTime,
+    );
+  }
+
+  @override
+  bool get wantKeepAlive => true;
+}

+ 182 - 0
frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart

@@ -0,0 +1,182 @@
+import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_calendar.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flowy_infra_ui/style_widget/decoration.dart';
+import 'package:flutter/material.dart';
+
+/// Provides arguemnts for [AppFlowyCalender] when showing
+/// a [DatePickerMenu]
+///
+class DatePickerOptions {
+  DatePickerOptions({
+    DateTime? focusedDay,
+    this.selectedDay,
+    this.firstDay,
+    this.lastDay,
+    this.includeTime = false,
+    this.timeFormat = UserTimeFormatPB.TwentyFourHour,
+    this.onDaySelected,
+    this.onIncludeTimeChanged,
+    this.onFormatChanged,
+    this.onPageChanged,
+    this.onTimeChanged,
+  }) : focusedDay = focusedDay ?? DateTime.now();
+
+  final DateTime focusedDay;
+  final DateTime? selectedDay;
+  final DateTime? firstDay;
+  final DateTime? lastDay;
+  final bool includeTime;
+  final UserTimeFormatPB timeFormat;
+
+  final DaySelectedCallback? onDaySelected;
+  final IncludeTimeChangedCallback? onIncludeTimeChanged;
+  final FormatChangedCallback? onFormatChanged;
+  final PageChangedCallback? onPageChanged;
+  final TimeChangedCallback? onTimeChanged;
+}
+
+abstract class DatePickerService {
+  void show(Offset offset);
+  void dismiss();
+}
+
+const double _datePickerWidth = 260;
+const double _datePickerHeight = 325;
+const double _includeTimeHeight = 60;
+const double _ySpacing = 15;
+
+class DatePickerMenu extends DatePickerService {
+  DatePickerMenu({
+    required this.context,
+    required this.editorState,
+  });
+
+  final BuildContext context;
+  final EditorState editorState;
+
+  OverlayEntry? _menuEntry;
+
+  @override
+  void dismiss() {
+    _menuEntry?.remove();
+    _menuEntry = null;
+  }
+
+  @override
+  void show(
+    Offset offset, {
+    DatePickerOptions? options,
+  }) =>
+      _show(offset, options: options);
+
+  void _show(
+    Offset offset, {
+    DatePickerOptions? options,
+  }) {
+    dismiss();
+
+    final editorSize = editorState.renderBox!.size;
+
+    double offsetX = offset.dx;
+    double offsetY = offset.dy;
+
+    final showRight = (offset.dx + _datePickerWidth) < editorSize.width;
+    if (!showRight) {
+      offsetX = offset.dx - _datePickerWidth;
+    }
+
+    final showBelow = (offset.dy + _datePickerHeight) < editorSize.height;
+    if (!showBelow) {
+      offsetY = offset.dy - _datePickerHeight;
+    }
+
+    _menuEntry = OverlayEntry(
+      builder: (context) {
+        return Material(
+          type: MaterialType.transparency,
+          child: SizedBox(
+            height: editorSize.height,
+            width: editorSize.width,
+            child: GestureDetector(
+              behavior: HitTestBehavior.opaque,
+              onTap: dismiss,
+              child: Stack(
+                children: [
+                  _AnimatedDatePicker(
+                    offset: Offset(offsetX, offsetY),
+                    showBelow: showBelow,
+                    options: options,
+                  ),
+                ],
+              ),
+            ),
+          ),
+        );
+      },
+    );
+
+    Overlay.of(context).insert(_menuEntry!);
+  }
+}
+
+class _AnimatedDatePicker extends StatefulWidget {
+  const _AnimatedDatePicker({
+    required this.offset,
+    required this.showBelow,
+    this.options,
+  });
+
+  final Offset offset;
+  final bool showBelow;
+  final DatePickerOptions? options;
+
+  @override
+  State<_AnimatedDatePicker> createState() => _AnimatedDatePickerState();
+}
+
+class _AnimatedDatePickerState extends State<_AnimatedDatePicker> {
+  late bool _includeTime = widget.options?.includeTime ?? false;
+
+  @override
+  Widget build(BuildContext context) {
+    double dy = widget.offset.dy;
+    if (!widget.showBelow && _includeTime) {
+      dy = dy - _includeTimeHeight;
+    }
+
+    dy = dy + (widget.showBelow ? _ySpacing : -_ySpacing);
+
+    return AnimatedPositioned(
+      duration: const Duration(milliseconds: 200),
+      top: dy,
+      left: widget.offset.dx,
+      child: Container(
+        decoration: FlowyDecoration.decoration(
+          Theme.of(context).cardColor,
+          Theme.of(context).colorScheme.shadow,
+        ),
+        constraints: BoxConstraints.loose(
+          const Size(_datePickerWidth, 465),
+        ),
+        child: AppFlowyCalendar(
+          focusedDay: widget.options?.focusedDay ?? DateTime.now(),
+          selectedDate: widget.options?.selectedDay,
+          firstDay: widget.options?.firstDay,
+          lastDay: widget.options?.lastDay,
+          includeTime: widget.options?.includeTime ?? false,
+          timeFormat:
+              widget.options?.timeFormat ?? UserTimeFormatPB.TwentyFourHour,
+          onDaySelected: widget.options?.onDaySelected,
+          onFormatChanged: widget.options?.onFormatChanged,
+          onPageChanged: widget.options?.onPageChanged,
+          onIncludeTimeChanged: (includeTime) {
+            widget.options?.onIncludeTimeChanged?.call(includeTime);
+            setState(() => _includeTime = includeTime);
+          },
+          onTimeChanged: widget.options?.onTimeChanged,
+        ),
+      ),
+    );
+  }
+}

+ 197 - 0
frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart

@@ -0,0 +1,197 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
+import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart';
+import 'package:appflowy/workspace/application/settings/date_time/time_patterns.dart';
+import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
+import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flowy_infra_ui/style_widget/text_field.dart';
+import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:flutter/material.dart';
+
+class IncludeTimeButton extends StatefulWidget {
+  const IncludeTimeButton({
+    super.key,
+    this.initialTime,
+    required this.popoverMutex,
+    this.includeTime = false,
+    this.onChanged,
+    this.onSubmitted,
+    this.timeFormat = UserTimeFormatPB.TwentyFourHour,
+  });
+
+  final String? initialTime;
+  final PopoverMutex? popoverMutex;
+  final bool includeTime;
+  final Function(bool includeTime)? onChanged;
+  final Function(String? time)? onSubmitted;
+  final UserTimeFormatPB timeFormat;
+
+  @override
+  State<IncludeTimeButton> createState() => _IncludeTimeButtonState();
+}
+
+class _IncludeTimeButtonState extends State<IncludeTimeButton> {
+  late bool _includeTime = widget.includeTime;
+  String? _timeString;
+
+  @override
+  void initState() {
+    super.initState();
+    _timeString = widget.initialTime;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: [
+        if (_includeTime) ...[
+          _TimeTextField(
+            timeStr: _timeString,
+            popoverMutex: widget.popoverMutex,
+            timeFormat: widget.timeFormat,
+            onSubmitted: (value) {
+              setState(() => _timeString = value);
+              widget.onSubmitted?.call(_timeString);
+            },
+          ),
+          const TypeOptionSeparator(spacing: 12.0),
+        ],
+        Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 12.0),
+          child: SizedBox(
+            height: GridSize.popoverItemHeight,
+            child: Padding(
+              padding: GridSize.typeOptionContentInsets -
+                  const EdgeInsets.only(top: 4),
+              child: Row(
+                children: [
+                  FlowySvg(
+                    FlowySvgs.clock_alarm_s,
+                    color: Theme.of(context).iconTheme.color,
+                  ),
+                  const HSpace(6),
+                  FlowyText.medium(LocaleKeys.grid_field_includeTime.tr()),
+                  const Spacer(),
+                  Toggle(
+                    value: _includeTime,
+                    onChanged: (value) {
+                      widget.onChanged?.call(!value);
+                      setState(() => _includeTime = !value);
+                    },
+                    style: ToggleStyle.big,
+                    padding: EdgeInsets.zero,
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class _TimeTextField extends StatefulWidget {
+  const _TimeTextField({
+    required this.timeStr,
+    required this.popoverMutex,
+    this.onSubmitted,
+    this.timeFormat = UserTimeFormatPB.TwentyFourHour,
+  });
+
+  final String? timeStr;
+  final PopoverMutex? popoverMutex;
+  final Function(String? value)? onSubmitted;
+  final UserTimeFormatPB timeFormat;
+
+  @override
+  State<_TimeTextField> createState() => _TimeTextFieldState();
+}
+
+class _TimeTextFieldState extends State<_TimeTextField> {
+  late final FocusNode _focusNode;
+  late final TextEditingController _textController;
+
+  late String? _timeString;
+
+  String? errorText;
+
+  @override
+  void initState() {
+    super.initState();
+
+    _timeString = widget.timeStr;
+    _focusNode = FocusNode();
+    _textController = TextEditingController()..text = _timeString ?? "";
+
+    _focusNode.addListener(() {
+      if (_focusNode.hasFocus) {
+        widget.popoverMutex?.close();
+      }
+    });
+
+    widget.popoverMutex?.listenOnPopoverChanged(() {
+      if (_focusNode.hasFocus) {
+        _focusNode.unfocus();
+      }
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: [
+        Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 12.0),
+          child: FlowyTextField(
+            text: _timeString ?? "",
+            focusNode: _focusNode,
+            controller: _textController,
+            submitOnLeave: true,
+            hintText: hintText,
+            errorText: errorText,
+            onSubmitted: (value) {
+              setState(() {
+                errorText = _validate(value);
+              });
+
+              if (errorText == null) {
+                widget.onSubmitted?.call(value);
+              }
+            },
+          ),
+        ),
+      ],
+    );
+  }
+
+  String? _validate(String value) {
+    final msg = LocaleKeys.grid_field_invalidTimeFormat.tr();
+
+    switch (widget.timeFormat) {
+      case UserTimeFormatPB.TwentyFourHour:
+        if (!isTwentyFourHourTime(value)) {
+          return "$msg. e.g. 13:00";
+        }
+      case UserTimeFormatPB.TwelveHour:
+        if (!isTwelveHourTime(value)) {
+          return "$msg. e.g. 01:00 PM";
+        }
+    }
+
+    return null;
+  }
+
+  String get hintText => switch (widget.timeFormat) {
+        UserTimeFormatPB.TwentyFourHour =>
+          LocaleKeys.document_date_timeHintTextInTwentyFourHour.tr(),
+        UserTimeFormatPB.TwelveHour =>
+          LocaleKeys.document_date_timeHintTextInTwelveHour.tr(),
+        _ => "",
+      };
+}

+ 4 - 1
frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart

@@ -17,18 +17,21 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
 import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
 
-// ignore: unused_import
 import 'package:protobuf/protobuf.dart';
 import 'dart:convert' show utf8;
 import '../protobuf/flowy-config/entities.pb.dart';
 import '../protobuf/flowy-config/event_map.pb.dart';
 import 'error.dart';
 
+import '../protobuf/flowy-date/entities.pb.dart';
+import '../protobuf/flowy-date/event_map.pb.dart';
+
 part 'dart_event/flowy-folder2/dart_event.dart';
 part 'dart_event/flowy-user/dart_event.dart';
 part 'dart_event/flowy-database2/dart_event.dart';
 part 'dart_event/flowy-document2/dart_event.dart';
 part 'dart_event/flowy-config/dart_event.dart';
+part 'dart_event/flowy-date/dart_event.dart';
 
 enum FFIException {
   RequestIsEmpty,

+ 1 - 2
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart

@@ -59,11 +59,10 @@ class FlowyColorPicker extends StatelessWidget {
 
     final colorIcon = SizedBox.square(
       dimension: iconSize,
-      child: Container(
+      child: DecoratedBox(
         decoration: BoxDecoration(
           color: option.color,
           shape: BoxShape.circle,
-          // border: border,
         ),
       ),
     );

+ 8 - 0
frontend/appflowy_flutter/pubspec.lock

@@ -825,6 +825,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "3.1.0"
+  local_notifier:
+    dependency: "direct main"
+    description:
+      name: local_notifier
+      sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.5"
   logger:
     dependency: transitive
     description:

+ 5 - 0
frontend/appflowy_flutter/pubspec.yaml

@@ -109,6 +109,11 @@ dependencies:
   super_clipboard: ^0.6.3
   go_router: ^10.1.2
 
+  # Notifications
+  # TODO: Consider implementing custom package
+  # to gather notification handling for all platforms
+  local_notifier: ^0.1.5
+
 dev_dependencies:
   flutter_lints: ^2.0.1
 

+ 7 - 0
frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart

@@ -17,9 +17,12 @@ void main() {
 
   group('$AppearanceSettingsCubit', () {
     late AppearanceSettingsPB appearanceSetting;
+    late DateTimeSettingsPB dateTimeSettings;
     setUp(() async {
       appearanceSetting =
           await UserSettingsBackendService().getAppearanceSetting();
+      dateTimeSettings =
+          await UserSettingsBackendService().getDateTimeSettings();
       await blocResponseFuture();
     });
 
@@ -27,6 +30,7 @@ void main() {
       'default theme',
       build: () => AppearanceSettingsCubit(
         appearanceSetting,
+        dateTimeSettings,
         AppTheme.fallback,
       ),
       verify: (bloc) {
@@ -41,6 +45,7 @@ void main() {
       'save key/value',
       build: () => AppearanceSettingsCubit(
         appearanceSetting,
+        dateTimeSettings,
         AppTheme.fallback,
       ),
       act: (bloc) {
@@ -55,6 +60,7 @@ void main() {
       'remove key/value',
       build: () => AppearanceSettingsCubit(
         appearanceSetting,
+        dateTimeSettings,
         AppTheme.fallback,
       ),
       act: (bloc) {
@@ -69,6 +75,7 @@ void main() {
       'initial state uses fallback theme',
       build: () => AppearanceSettingsCubit(
         appearanceSetting,
+        dateTimeSettings,
         AppTheme.fallback,
       ),
       verify: (bloc) {

+ 41 - 11
frontend/appflowy_tauri/src-tauri/Cargo.lock

@@ -798,7 +798,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -817,7 +817,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -847,7 +847,7 @@ dependencies = [
 [[package]]
 name = "collab-define"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "collab",
@@ -859,7 +859,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -871,7 +871,7 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "collab",
@@ -891,7 +891,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "chrono",
@@ -931,7 +931,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "async-trait",
  "bincode",
@@ -952,7 +952,7 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -980,7 +980,7 @@ dependencies = [
 [[package]]
 name = "collab-sync-protocol"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "bytes",
  "collab",
@@ -995,7 +995,7 @@ dependencies = [
 [[package]]
 name = "collab-user"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "collab",
@@ -1344,6 +1344,16 @@ dependencies = [
  "parking_lot_core",
 ]
 
+[[package]]
+name = "date_time_parser"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a"
+dependencies = [
+ "chrono",
+ "regex",
+]
+
 [[package]]
 name = "derivative"
 version = "2.2.0"
@@ -1774,6 +1784,7 @@ dependencies = [
  "flowy-config",
  "flowy-database-deps",
  "flowy-database2",
+ "flowy-date",
  "flowy-document-deps",
  "flowy-document2",
  "flowy-error",
@@ -1853,6 +1864,23 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "flowy-date"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "chrono",
+ "date_time_parser",
+ "fancy-regex 0.11.0",
+ "flowy-codegen",
+ "flowy-derive",
+ "flowy-error",
+ "lib-dispatch",
+ "protobuf",
+ "strum_macros 0.21.1",
+ "tracing",
+]
+
 [[package]]
 name = "flowy-derive"
 version = "0.1.0"
@@ -1932,6 +1960,7 @@ dependencies = [
  "client-api",
  "collab-database",
  "collab-document",
+ "fancy-regex 0.11.0",
  "flowy-codegen",
  "flowy-derive",
  "flowy-sqlite",
@@ -2134,7 +2163,8 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_repr",
- "strum_macros 0.21.1",
+ "strum",
+ "strum_macros 0.25.2",
  "tokio",
  "tracing",
  "unicode-segmentation",

+ 20 - 17
frontend/appflowy_tauri/src-tauri/Cargo.toml

@@ -20,9 +20,16 @@ tauri = { version = "1.2", features = ["fs-all", "shell-open"] }
 tauri-utils = "1.2"
 bytes = { version = "1.4" }
 tracing = { version = "0.1", features = ["log"] }
-lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = ["use_serde"] }
-flowy-core = { path = "../../rust-lib/flowy-core", features = ["rev-sqlite", "ts"] }
-flowy-notification = { path = "../../rust-lib/flowy-notification", features = ["ts"] }
+lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [
+    "use_serde",
+] }
+flowy-core = { path = "../../rust-lib/flowy-core", features = [
+    "rev-sqlite",
+    "ts",
+] }
+flowy-notification = { path = "../../rust-lib/flowy-notification", features = [
+    "ts",
+] }
 
 [features]
 # by default Tauri runs in production mode
@@ -40,21 +47,17 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "8f8
 # Working directory: frontend
 #
 # To update the commit ID, run:
-# scripts/tool/update_collab_rev.sh new_rev_id
+# scripts/tool/update_collab_rev.sh e37ee7
 #
 # To switch to the local path, run:
 # scripts/tool/update_collab_source.sh
 # ⚠️⚠️⚠️️
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-
-
-
-
+collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }

+ 7 - 7
frontend/appflowy_tauri/src/services/backend/index.ts

@@ -1,7 +1,7 @@
-export * from "./models/flowy-user";
-export * from "./models/flowy-database2";
-export * from "./models/flowy-folder2";
-export * from "./models/flowy-document2";
-export * from "./models/flowy-error";
-export * from "./models/flowy-config";
-
+export * from './models/flowy-user';
+export * from './models/flowy-database2';
+export * from './models/flowy-folder2';
+export * from './models/flowy-document2';
+export * from './models/flowy-error';
+export * from './models/flowy-config';
+export * from './models/flowy-date';

+ 39 - 0
frontend/resources/translations/en.json

@@ -291,6 +291,19 @@
       "theme": "Theme",
       "builtInsLabel": "Built-in Themes",
       "pluginsLabel": "Plugins",
+      "dateFormat": {
+        "label": "Date format",
+        "local": "Local",
+        "us": "US",
+        "iso": "ISO",
+        "friendly": "Friendly",
+        "dmy": "D/M/Y"
+      },
+      "timeFormat": {
+        "label": "Time format",
+        "twelveHour": "Twelve hour",
+        "twentyFourHour": "Twenty four hour"
+      },
       "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page"
     },
     "files": {
@@ -755,6 +768,32 @@
       "frequentlyUsed": "Frequently Used"
     }
   },
+  "inlineActions": {
+    "noResults": "No results",
+    "pageReference": "Page reference",
+    "date": "Date",
+    "reminder": {
+      "groupTitle": "Reminder",
+      "shortKeyword": "remind"
+    }
+  },
+  "relativeDates": {
+    "yesterday": "Yesterday",
+    "today": "Today",
+    "tomorrow": "Tomorrow",
+    "oneWeek": "1 week"
+  },
+  "notificationHub": {
+    "title": "Notifications",
+    "empty": "Nothing to see here!"
+  },
+  "reminderNotification": {
+    "title": "Reminder",
+    "message": "Remember to check this before you forget!",
+    "tooltipDelete": "Delete",
+    "tooltipMarkRead": "Mark as read",
+    "tooltipMarkUnread": "Mark as unread"
+  },
   "findAndReplace": {
     "find": "Find",
     "previousMatch": "Previous match",

+ 42 - 12
frontend/rust-lib/Cargo.lock

@@ -421,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b"
 dependencies = [
  "borsh-derive",
- "hashbrown 0.13.2",
+ "hashbrown 0.12.3",
 ]
 
 [[package]]
@@ -672,7 +672,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -691,7 +691,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -721,7 +721,7 @@ dependencies = [
 [[package]]
 name = "collab-define"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "collab",
@@ -733,7 +733,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -745,7 +745,7 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "collab",
@@ -765,7 +765,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "chrono",
@@ -805,7 +805,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "async-trait",
  "bincode",
@@ -826,7 +826,7 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -854,7 +854,7 @@ dependencies = [
 [[package]]
 name = "collab-sync-protocol"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "bytes",
  "collab",
@@ -869,7 +869,7 @@ dependencies = [
 [[package]]
 name = "collab-user"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
 dependencies = [
  "anyhow",
  "collab",
@@ -1168,6 +1168,16 @@ dependencies = [
  "parking_lot_core",
 ]
 
+[[package]]
+name = "date_time_parser"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a"
+dependencies = [
+ "chrono",
+ "regex",
+]
+
 [[package]]
 name = "deranged"
 version = "0.3.8"
@@ -1517,6 +1527,7 @@ dependencies = [
  "flowy-config",
  "flowy-database-deps",
  "flowy-database2",
+ "flowy-date",
  "flowy-document-deps",
  "flowy-document2",
  "flowy-error",
@@ -1597,6 +1608,23 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "flowy-date"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "chrono",
+ "date_time_parser",
+ "fancy-regex 0.11.0",
+ "flowy-codegen",
+ "flowy-derive",
+ "flowy-error",
+ "lib-dispatch",
+ "protobuf",
+ "strum_macros 0.21.1",
+ "tracing",
+]
+
 [[package]]
 name = "flowy-derive"
 version = "0.1.0"
@@ -1678,6 +1706,7 @@ dependencies = [
  "client-api",
  "collab-database",
  "collab-document",
+ "fancy-regex 0.11.0",
  "flowy-codegen",
  "flowy-derive",
  "flowy-sqlite",
@@ -1943,7 +1972,8 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_repr",
- "strum_macros 0.21.1",
+ "strum",
+ "strum_macros 0.25.2",
  "tokio",
  "tracing",
  "unicode-segmentation",

+ 11 - 9
frontend/rust-lib/Cargo.toml

@@ -24,6 +24,7 @@ members = [
   "flowy-storage",
   "collab-integrate",
   "flowy-ai",
+  "flowy-date",
 ]
 
 [workspace.dependencies]
@@ -50,6 +51,7 @@ flowy-encrypt = { workspace = true, path = "flowy-encrypt" }
 flowy-storage = { workspace = true, path = "flowy-storage" }
 collab-integrate = { workspace = true, path = "collab-integrate" }
 flowy-ai = { workspace = true, path = "flowy-ai" }
+flowy-date = { workspace = true, path = "flowy-date" }
 
 [profile.dev]
 opt-level = 0
@@ -87,12 +89,12 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "8f8
 # To switch to the local path, run:
 # scripts/tool/update_collab_source.sh
 # ⚠️⚠️⚠️️
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
-collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
+collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
+collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }

+ 4 - 3
frontend/rust-lib/flowy-core/Cargo.toml

@@ -22,6 +22,7 @@ flowy-task = { workspace = true }
 flowy-server = { workspace = true }
 flowy-server-config = { workspace = true }
 flowy-config = { workspace = true }
+flowy-date = { workspace = true }
 collab-integrate = { workspace = true, features = ["supabase_integrate", "appflowy_cloud_integrate", "snapshot_plugin"] }
 flowy-ai = { workspace = true }
 collab-define = { version = "0.1.0" }
@@ -52,6 +53,7 @@ native_sync = []
 use_bunyan = ["lib-log/use_bunyan"]
 dart = [
     "flowy-user/dart",
+    "flowy-date/dart",
     "flowy-folder2/dart",
     "flowy-database2/dart",
     "flowy-document2/dart",
@@ -59,13 +61,12 @@ dart = [
 ]
 ts = [
     "flowy-user/ts",
+    "flowy-date/ts",
     "flowy-folder2/ts",
     "flowy-database2/ts",
     "flowy-document2/ts",
     "flowy-config/ts",
 ]
-rev-sqlite = [
-    "flowy-user/rev-sqlite",
-]
+rev-sqlite = ["flowy-user/rev-sqlite"]
 openssl_vendored = ["flowy-sqlite/openssl_vendored"]
 

+ 65 - 0
frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs

@@ -0,0 +1,65 @@
+use collab_define::reminder::Reminder;
+use std::convert::TryFrom;
+use std::sync::Weak;
+
+use flowy_database2::DatabaseManager;
+use flowy_document2::manager::DocumentManager;
+use flowy_document2::reminder::{DocumentReminder, DocumentReminderAction};
+use flowy_folder_deps::cloud::Error;
+use flowy_user::services::collab_interact::CollabInteract;
+use lib_infra::future::FutureResult;
+
+pub struct CollabInteractImpl {
+  #[allow(dead_code)]
+  pub(crate) database_manager: Weak<DatabaseManager>,
+  #[allow(dead_code)]
+  pub(crate) document_manager: Weak<DocumentManager>,
+}
+
+impl CollabInteract for CollabInteractImpl {
+  fn add_reminder(&self, reminder: Reminder) -> FutureResult<(), Error> {
+    let cloned_document_manager = self.document_manager.clone();
+    FutureResult::new(async move {
+      if let Some(document_manager) = cloned_document_manager.upgrade() {
+        match DocumentReminder::try_from(reminder) {
+          Ok(reminder) => {
+            document_manager
+              .handle_reminder_action(DocumentReminderAction::Add { reminder })
+              .await;
+          },
+          Err(e) => tracing::error!("Failed to convert reminder: {:?}", e),
+        }
+      }
+      Ok(())
+    })
+  }
+
+  fn remove_reminder(&self, reminder_id: &str) -> FutureResult<(), Error> {
+    let reminder_id = reminder_id.to_string();
+    let cloned_document_manager = self.document_manager.clone();
+    FutureResult::new(async move {
+      if let Some(document_manager) = cloned_document_manager.upgrade() {
+        let action = DocumentReminderAction::Remove { reminder_id };
+        document_manager.handle_reminder_action(action).await;
+      }
+      Ok(())
+    })
+  }
+
+  fn update_reminder(&self, reminder: Reminder) -> FutureResult<(), Error> {
+    let cloned_document_manager = self.document_manager.clone();
+    FutureResult::new(async move {
+      if let Some(document_manager) = cloned_document_manager.upgrade() {
+        match DocumentReminder::try_from(reminder) {
+          Ok(reminder) => {
+            document_manager
+              .handle_reminder_action(DocumentReminderAction::Update { reminder })
+              .await;
+          },
+          Err(e) => tracing::error!("Failed to convert reminder: {:?}", e),
+        }
+      }
+      Ok(())
+    })
+  }
+}

+ 48 - 0
frontend/rust-lib/flowy-core/src/integrate/log.rs

@@ -0,0 +1,48 @@
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use crate::AppFlowyCoreConfig;
+
+static INIT_LOG: AtomicBool = AtomicBool::new(false);
+pub(crate) fn init_log(config: &AppFlowyCoreConfig) {
+  if !INIT_LOG.load(Ordering::SeqCst) {
+    INIT_LOG.store(true, Ordering::SeqCst);
+
+    let _ = lib_log::Builder::new("AppFlowy-Client", &config.storage_path)
+      .env_filter(&config.log_filter)
+      .build();
+  }
+}
+pub(crate) fn create_log_filter(level: String, with_crates: Vec<String>) -> String {
+  let level = std::env::var("RUST_LOG").unwrap_or(level);
+  let mut filters = with_crates
+    .into_iter()
+    .map(|crate_name| format!("{}={}", crate_name, level))
+    .collect::<Vec<String>>();
+  filters.push(format!("flowy_core={}", level));
+  filters.push(format!("flowy_folder2={}", level));
+  filters.push(format!("collab_sync={}", level));
+  filters.push(format!("collab_folder={}", level));
+  filters.push(format!("collab_persistence={}", level));
+  filters.push(format!("collab_database={}", level));
+  filters.push(format!("collab_plugins={}", level));
+  filters.push(format!("appflowy_integrate={}", level));
+  filters.push(format!("collab={}", level));
+  filters.push(format!("flowy_user={}", level));
+  filters.push(format!("flowy_document2={}", level));
+  filters.push(format!("flowy_database2={}", level));
+  filters.push(format!("flowy_server={}", level));
+  filters.push(format!("flowy_notification={}", "info"));
+  filters.push(format!("lib_infra={}", level));
+  filters.push(format!("flowy_task={}", level));
+
+  filters.push(format!("dart_ffi={}", "info"));
+  filters.push(format!("flowy_sqlite={}", "info"));
+  filters.push(format!("flowy_net={}", level));
+  #[cfg(feature = "profiling")]
+  filters.push(format!("tokio={}", level));
+
+  #[cfg(feature = "profiling")]
+  filters.push(format!("runtime={}", level));
+
+  filters.join(",")
+}

+ 3 - 0
frontend/rust-lib/flowy-core/src/integrate/mod.rs

@@ -1,2 +1,5 @@
+pub(crate) mod collab_interact;
+pub(crate) mod log;
 pub(crate) mod server;
 mod trait_impls;
+pub(crate) mod user;

+ 187 - 0
frontend/rust-lib/flowy-core/src/integrate/user.rs

@@ -0,0 +1,187 @@
+use std::sync::Arc;
+
+use collab_integrate::collab_builder::AppFlowyCollabBuilder;
+use flowy_database2::DatabaseManager;
+use flowy_document2::manager::DocumentManager;
+use flowy_error::FlowyResult;
+use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager};
+use flowy_user::event_map::{UserCloudServiceProvider, UserStatusCallback};
+use flowy_user_deps::cloud::UserCloudConfig;
+use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace};
+use lib_infra::future::{to_fut, Fut};
+
+use crate::integrate::server::ServerProvider;
+use crate::AppFlowyCoreConfig;
+
+pub(crate) struct UserStatusCallbackImpl {
+  pub(crate) collab_builder: Arc<AppFlowyCollabBuilder>,
+  pub(crate) folder_manager: Arc<FolderManager>,
+  pub(crate) database_manager: Arc<DatabaseManager>,
+  pub(crate) document_manager: Arc<DocumentManager>,
+  pub(crate) server_provider: Arc<ServerProvider>,
+  #[allow(dead_code)]
+  pub(crate) config: AppFlowyCoreConfig,
+}
+
+impl UserStatusCallback for UserStatusCallbackImpl {
+  fn auth_type_did_changed(&self, _auth_type: AuthType) {}
+
+  fn did_init(
+    &self,
+    user_id: i64,
+    cloud_config: &Option<UserCloudConfig>,
+    user_workspace: &UserWorkspace,
+    _device_id: &str,
+  ) -> Fut<FlowyResult<()>> {
+    let user_id = user_id.to_owned();
+    let user_workspace = user_workspace.clone();
+    let collab_builder = self.collab_builder.clone();
+    let folder_manager = self.folder_manager.clone();
+    let database_manager = self.database_manager.clone();
+    let document_manager = self.document_manager.clone();
+
+    if let Some(cloud_config) = cloud_config {
+      self
+        .server_provider
+        .set_enable_sync(user_id, cloud_config.enable_sync);
+      if cloud_config.enable_encrypt() {
+        self
+          .server_provider
+          .set_encrypt_secret(cloud_config.encrypt_secret.clone());
+      }
+    }
+
+    to_fut(async move {
+      collab_builder.initialize(user_workspace.id.clone());
+      folder_manager
+        .initialize(
+          user_id,
+          &user_workspace.id,
+          FolderInitializeDataSource::LocalDisk {
+            create_if_not_exist: false,
+          },
+        )
+        .await?;
+      database_manager
+        .initialize(
+          user_id,
+          user_workspace.id.clone(),
+          user_workspace.database_views_aggregate_id,
+        )
+        .await?;
+      document_manager
+        .initialize(user_id, user_workspace.id)
+        .await?;
+      Ok(())
+    })
+  }
+
+  fn did_sign_in(
+    &self,
+    user_id: i64,
+    user_workspace: &UserWorkspace,
+    _device_id: &str,
+  ) -> Fut<FlowyResult<()>> {
+    let user_id = user_id.to_owned();
+    let user_workspace = user_workspace.clone();
+    let folder_manager = self.folder_manager.clone();
+    let database_manager = self.database_manager.clone();
+    let document_manager = self.document_manager.clone();
+
+    to_fut(async move {
+      folder_manager
+        .initialize_with_workspace_id(user_id, &user_workspace.id)
+        .await?;
+      database_manager
+        .initialize(
+          user_id,
+          user_workspace.id.clone(),
+          user_workspace.database_views_aggregate_id,
+        )
+        .await?;
+      document_manager
+        .initialize(user_id, user_workspace.id)
+        .await?;
+      Ok(())
+    })
+  }
+
+  fn did_sign_up(
+    &self,
+    is_new_user: bool,
+    user_profile: &UserProfile,
+    user_workspace: &UserWorkspace,
+    _device_id: &str,
+  ) -> Fut<FlowyResult<()>> {
+    let user_profile = user_profile.clone();
+    let folder_manager = self.folder_manager.clone();
+    let database_manager = self.database_manager.clone();
+    let user_workspace = user_workspace.clone();
+    let document_manager = self.document_manager.clone();
+
+    to_fut(async move {
+      folder_manager
+        .initialize_with_new_user(
+          user_profile.uid,
+          &user_profile.token,
+          is_new_user,
+          FolderInitializeDataSource::LocalDisk {
+            create_if_not_exist: true,
+          },
+          &user_workspace.id,
+        )
+        .await?;
+      database_manager
+        .initialize_with_new_user(
+          user_profile.uid,
+          user_workspace.id.clone(),
+          user_workspace.database_views_aggregate_id,
+        )
+        .await?;
+
+      document_manager
+        .initialize_with_new_user(user_profile.uid, user_workspace.id)
+        .await?;
+      Ok(())
+    })
+  }
+
+  fn did_expired(&self, _token: &str, user_id: i64) -> Fut<FlowyResult<()>> {
+    let folder_manager = self.folder_manager.clone();
+    to_fut(async move {
+      folder_manager.clear(user_id).await;
+      Ok(())
+    })
+  }
+
+  fn open_workspace(&self, user_id: i64, user_workspace: &UserWorkspace) -> Fut<FlowyResult<()>> {
+    let user_workspace = user_workspace.clone();
+    let collab_builder = self.collab_builder.clone();
+    let folder_manager = self.folder_manager.clone();
+    let database_manager = self.database_manager.clone();
+    let document_manager = self.document_manager.clone();
+
+    to_fut(async move {
+      collab_builder.initialize(user_workspace.id.clone());
+      folder_manager
+        .initialize_with_workspace_id(user_id, &user_workspace.id)
+        .await?;
+
+      database_manager
+        .initialize(
+          user_id,
+          user_workspace.id.clone(),
+          user_workspace.database_views_aggregate_id,
+        )
+        .await?;
+      document_manager
+        .initialize(user_id, user_workspace.id)
+        .await?;
+      Ok(())
+    })
+  }
+
+  fn did_update_network(&self, reachable: bool) {
+    self.collab_builder.update_network(reachable);
+  }
+}

+ 19 - 238
frontend/rust-lib/flowy-core/src/lib.rs

@@ -2,43 +2,34 @@
 
 use std::sync::Weak;
 use std::time::Duration;
-use std::{
-  fmt,
-  sync::{
-    atomic::{AtomicBool, Ordering},
-    Arc,
-  },
-};
+use std::{fmt, sync::Arc};
 
 use tokio::sync::RwLock;
 
 use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabSource};
 use flowy_database2::DatabaseManager;
 use flowy_document2::manager::DocumentManager;
-use flowy_error::FlowyResult;
-use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager};
+use flowy_folder2::manager::FolderManager;
 use flowy_sqlite::kv::StorePreferences;
 use flowy_storage::FileStorageService;
 use flowy_task::{TaskDispatcher, TaskRunner};
-use flowy_user::event_map::{UserCloudServiceProvider, UserStatusCallback};
+use flowy_user::event_map::UserCloudServiceProvider;
 use flowy_user::manager::{UserManager, UserSessionConfig};
-use flowy_user_deps::cloud::UserCloudConfig;
-use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace};
 use lib_dispatch::prelude::*;
 use lib_dispatch::runtime::tokio_default_runtime;
-use lib_infra::future::{to_fut, Fut};
 use module::make_plugins;
 pub use module::*;
 
 use crate::deps_resolve::*;
+use crate::integrate::collab_interact::CollabInteractImpl;
+use crate::integrate::log::{create_log_filter, init_log};
 use crate::integrate::server::{current_server_provider, ServerProvider, ServerType};
+use crate::integrate::user::UserStatusCallbackImpl;
 
 mod deps_resolve;
 mod integrate;
 pub mod module;
 
-static INIT_LOG: AtomicBool = AtomicBool::new(false);
-
 /// This name will be used as to identify the current [AppFlowyCore] instance.
 /// Don't change this.
 pub const DEFAULT_NAME: &str = "appflowy";
@@ -75,41 +66,6 @@ impl AppFlowyCoreConfig {
   }
 }
 
-fn create_log_filter(level: String, with_crates: Vec<String>) -> String {
-  let level = std::env::var("RUST_LOG").unwrap_or(level);
-  let mut filters = with_crates
-    .into_iter()
-    .map(|crate_name| format!("{}={}", crate_name, level))
-    .collect::<Vec<String>>();
-  filters.push(format!("flowy_core={}", level));
-  filters.push(format!("flowy_folder2={}", level));
-  filters.push(format!("collab_sync={}", level));
-  filters.push(format!("collab_folder={}", level));
-  filters.push(format!("collab_persistence={}", level));
-  filters.push(format!("collab_database={}", level));
-  filters.push(format!("collab_plugins={}", level));
-  filters.push(format!("appflowy_integrate={}", level));
-  filters.push(format!("collab={}", level));
-  filters.push(format!("flowy_user={}", level));
-  filters.push(format!("flowy_document2={}", level));
-  filters.push(format!("flowy_database2={}", level));
-  filters.push(format!("flowy_server={}", level));
-  filters.push(format!("flowy_notification={}", "info"));
-  filters.push(format!("lib_infra={}", level));
-  filters.push(format!("flowy_task={}", level));
-
-  filters.push(format!("dart_ffi={}", "info"));
-  filters.push(format!("flowy_sqlite={}", "info"));
-  filters.push(format!("flowy_net={}", level));
-  #[cfg(feature = "profiling")]
-  filters.push(format!("tokio={}", level));
-
-  #[cfg(feature = "profiling")]
-  filters.push(format!("runtime={}", level));
-
-  filters.join(",")
-}
-
 #[derive(Clone)]
 pub struct AppFlowyCore {
   #[allow(dead_code)]
@@ -162,7 +118,7 @@ impl AppFlowyCore {
       /// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded
       /// on demand based on the [CollabPluginConfig].
       let collab_builder = Arc::new(AppFlowyCollabBuilder::new(server_provider.clone()));
-      let user_manager = mk_user_session(
+      let user_manager = init_user_manager(
         &config,
         &store_preference,
         server_provider.clone(),
@@ -206,7 +162,7 @@ impl AppFlowyCore {
       )
     });
 
-    let user_status_listener = UserStatusCallbackImpl {
+    let user_status_callback = UserStatusCallbackImpl {
       collab_builder,
       folder_manager: folder_manager.clone(),
       database_manager: database_manager.clone(),
@@ -215,10 +171,17 @@ impl AppFlowyCore {
       config: config.clone(),
     };
 
+    let collab_interact_impl = CollabInteractImpl {
+      database_manager: Arc::downgrade(&database_manager),
+      document_manager: Arc::downgrade(&document_manager),
+    };
+
     let cloned_user_session = Arc::downgrade(&user_manager);
     runtime.block_on(async move {
       if let Some(user_session) = cloned_user_session.upgrade() {
-        user_session.init(user_status_listener).await;
+        user_session
+          .init(user_status_callback, collab_interact_impl)
+          .await;
       }
     });
 
@@ -250,17 +213,7 @@ impl AppFlowyCore {
   }
 }
 
-fn init_log(config: &AppFlowyCoreConfig) {
-  if !INIT_LOG.load(Ordering::SeqCst) {
-    INIT_LOG.store(true, Ordering::SeqCst);
-
-    let _ = lib_log::Builder::new("AppFlowy-Client", &config.storage_path)
-      .env_filter(&config.log_filter)
-      .build();
-  }
-}
-
-fn mk_user_session(
+fn init_user_manager(
   config: &AppFlowyCoreConfig,
   storage_preference: &Arc<StorePreferences>,
   user_cloud_service_provider: Arc<dyn UserCloudServiceProvider>,
@@ -275,181 +228,9 @@ fn mk_user_session(
   )
 }
 
-struct UserStatusCallbackImpl {
-  collab_builder: Arc<AppFlowyCollabBuilder>,
-  folder_manager: Arc<FolderManager>,
-  database_manager: Arc<DatabaseManager>,
-  document_manager: Arc<DocumentManager>,
-  server_provider: Arc<ServerProvider>,
-  #[allow(dead_code)]
-  config: AppFlowyCoreConfig,
-}
-
-impl UserStatusCallback for UserStatusCallbackImpl {
-  fn auth_type_did_changed(&self, _auth_type: AuthType) {}
-
-  fn did_init(
-    &self,
-    user_id: i64,
-    cloud_config: &Option<UserCloudConfig>,
-    user_workspace: &UserWorkspace,
-    _device_id: &str,
-  ) -> Fut<FlowyResult<()>> {
-    let user_workspace = user_workspace.clone();
-    self.collab_builder.initialize(user_workspace.id.clone());
-
-    let folder_manager = self.folder_manager.clone();
-    let database_manager = self.database_manager.clone();
-    let document_manager = self.document_manager.clone();
-
-    if let Some(cloud_config) = cloud_config {
-      self
-        .server_provider
-        .set_enable_sync(user_id, cloud_config.enable_sync);
-      if cloud_config.enable_encrypt() {
-        self
-          .server_provider
-          .set_encrypt_secret(cloud_config.encrypt_secret.clone());
-      }
-    }
-
-    to_fut(async move {
-      folder_manager
-        .initialize(
-          user_id,
-          &user_workspace.id,
-          FolderInitializeDataSource::LocalDisk {
-            create_if_not_exist: false,
-          },
-        )
-        .await?;
-      database_manager
-        .initialize(
-          user_id,
-          user_workspace.id.clone(),
-          user_workspace.database_views_aggregate_id,
-        )
-        .await?;
-      document_manager
-        .initialize(user_id, user_workspace.id)
-        .await?;
-      Ok(())
-    })
-  }
-
-  fn did_sign_in(
-    &self,
-    user_id: i64,
-    user_workspace: &UserWorkspace,
-    _device_id: &str,
-  ) -> Fut<FlowyResult<()>> {
-    let user_id = user_id.to_owned();
-    let user_workspace = user_workspace.clone();
-    let folder_manager = self.folder_manager.clone();
-    let database_manager = self.database_manager.clone();
-    let document_manager = self.document_manager.clone();
-
-    to_fut(async move {
-      folder_manager
-        .initialize_with_workspace_id(user_id, &user_workspace.id)
-        .await?;
-      database_manager
-        .initialize(
-          user_id,
-          user_workspace.id.clone(),
-          user_workspace.database_views_aggregate_id,
-        )
-        .await?;
-      document_manager
-        .initialize(user_id, user_workspace.id)
-        .await?;
-      Ok(())
-    })
-  }
-
-  fn did_sign_up(
-    &self,
-    is_new_user: bool,
-    user_profile: &UserProfile,
-    user_workspace: &UserWorkspace,
-    _device_id: &str,
-  ) -> Fut<FlowyResult<()>> {
-    let user_profile = user_profile.clone();
-    let folder_manager = self.folder_manager.clone();
-    let database_manager = self.database_manager.clone();
-    let user_workspace = user_workspace.clone();
-    let document_manager = self.document_manager.clone();
-
-    to_fut(async move {
-      folder_manager
-        .initialize_with_new_user(
-          user_profile.uid,
-          &user_profile.token,
-          is_new_user,
-          FolderInitializeDataSource::LocalDisk {
-            create_if_not_exist: true,
-          },
-          &user_workspace.id,
-        )
-        .await?;
-      database_manager
-        .initialize_with_new_user(
-          user_profile.uid,
-          user_workspace.id.clone(),
-          user_workspace.database_views_aggregate_id,
-        )
-        .await?;
-
-      document_manager
-        .initialize_with_new_user(user_profile.uid, user_workspace.id)
-        .await?;
-      Ok(())
-    })
-  }
-
-  fn did_expired(&self, _token: &str, user_id: i64) -> Fut<FlowyResult<()>> {
-    let folder_manager = self.folder_manager.clone();
-    to_fut(async move {
-      folder_manager.clear(user_id).await;
-      Ok(())
-    })
-  }
-
-  fn open_workspace(&self, user_id: i64, user_workspace: &UserWorkspace) -> Fut<FlowyResult<()>> {
-    let user_workspace = user_workspace.clone();
-    self.collab_builder.initialize(user_workspace.id.clone());
-
-    let folder_manager = self.folder_manager.clone();
-    let database_manager = self.database_manager.clone();
-    let document_manager = self.document_manager.clone();
-
-    to_fut(async move {
-      folder_manager
-        .initialize_with_workspace_id(user_id, &user_workspace.id)
-        .await?;
-
-      database_manager
-        .initialize(
-          user_id,
-          user_workspace.id.clone(),
-          user_workspace.database_views_aggregate_id,
-        )
-        .await?;
-      document_manager
-        .initialize(user_id, user_workspace.id)
-        .await?;
-      Ok(())
-    })
-  }
-
-  fn did_update_network(&self, reachable: bool) {
-    self.collab_builder.update_network(reachable);
-  }
-}
-
 impl From<ServerType> for CollabSource {
-  fn from(server_provider: ServerType) -> Self {
-    match server_provider {
+  fn from(server_type: ServerType) -> Self {
+    match server_type {
       ServerType::Local => CollabSource::Local,
       ServerType::AppFlowyCloud => CollabSource::Local,
       ServerType::Supabase => CollabSource::Supabase,

+ 2 - 0
frontend/rust-lib/flowy-core/src/module.rs

@@ -21,11 +21,13 @@ pub fn make_plugins(
   let database_plugin = flowy_database2::event_map::init(database_manager);
   let document_plugin2 = flowy_document2::event_map::init(document_manager2);
   let config_plugin = flowy_config::event_map::init(store_preferences);
+  let date_plugin = flowy_date::event_map::init();
   vec![
     user_plugin,
     folder_plugin,
     database_plugin,
     document_plugin2,
     config_plugin,
+    date_plugin,
   ]
 }

+ 1 - 1
frontend/rust-lib/flowy-database2/src/services/group/configuration.rs

@@ -301,7 +301,7 @@ where
             is_changed = true;
           },
           Some(pos) => {
-            let mut old_group = configuration.groups.get_mut(pos).unwrap();
+            let old_group = configuration.groups.get_mut(pos).unwrap();
             // Take the old group setting
             group.visible = old_group.visible;
             if !is_changed {

+ 25 - 0
frontend/rust-lib/flowy-date/Cargo.toml

@@ -0,0 +1,25 @@
+[package]
+name = "flowy-date"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+lib-dispatch = { path = "../lib-dispatch" }
+flowy-error = { path = "../flowy-error" }
+flowy-derive = { path = "../../../shared-lib/flowy-derive" }
+protobuf = { version = "2.28.0" }
+bytes = { version = "1.4" }
+strum_macros = "0.21"
+tracing = { version = "0.1" }
+date_time_parser = { version = "0.2.0" }
+chrono = { version = "0.4.26" }
+fancy-regex = { version = "0.11.0" }
+
+[features]
+dart = ["flowy-codegen/dart"]
+ts = ["flowy-codegen/ts"]
+
+[build-dependencies]
+flowy-codegen = { path = "../../../shared-lib/flowy-codegen" }

+ 3 - 0
frontend/rust-lib/flowy-date/Flowy.toml

@@ -0,0 +1,3 @@
+# Check out the FlowyConfig (located in flowy_toml.rs) for more details.
+proto_input = ["src/event_map.rs", "src/entities.rs"]
+event_files = ["src/event_map.rs"]

+ 10 - 0
frontend/rust-lib/flowy-date/build.rs

@@ -0,0 +1,10 @@
+fn main() {
+  let crate_name = env!("CARGO_PKG_NAME");
+  flowy_codegen::protobuf_file::gen(crate_name);
+
+  #[cfg(feature = "dart")]
+  flowy_codegen::dart_event::gen(crate_name);
+
+  #[cfg(feature = "ts")]
+  flowy_codegen::ts_event::gen(crate_name);
+}

+ 13 - 0
frontend/rust-lib/flowy-date/src/entities.rs

@@ -0,0 +1,13 @@
+use flowy_derive::ProtoBuf;
+
+#[derive(ProtoBuf, Debug, Default, Clone)]
+pub struct DateQueryPB {
+  #[pb(index = 1)]
+  pub query: String,
+}
+
+#[derive(ProtoBuf, Debug, Default, Clone)]
+pub struct DateResultPB {
+  #[pb(index = 1)]
+  pub date: String,
+}

+ 36 - 0
frontend/rust-lib/flowy-date/src/event_handler.rs

@@ -0,0 +1,36 @@
+use chrono::{Datelike, NaiveDate};
+use date_time_parser::DateParser;
+use fancy_regex::Regex;
+use flowy_error::FlowyError;
+use lib_dispatch::prelude::{data_result_ok, AFPluginData, DataResult};
+use std::sync::OnceLock;
+
+use crate::entities::*;
+
+static YEAR_REGEX: OnceLock<Regex> = OnceLock::new();
+
+fn year_regex() -> &'static Regex {
+  YEAR_REGEX.get_or_init(|| Regex::new(r"\b\d{4}\b").unwrap())
+}
+
+#[tracing::instrument(level = "debug", skip_all, err)]
+pub(crate) async fn query_date_handler(
+  data: AFPluginData<DateQueryPB>,
+) -> DataResult<DateResultPB, FlowyError> {
+  let query: String = data.into_inner().query;
+  let date = DateParser::parse(&query);
+
+  match date {
+    Some(naive_date) => {
+      let year_match = year_regex().find(&query).unwrap();
+      let formatted = year_match
+        .and_then(|capture| capture.as_str().parse::<i32>().ok())
+        .and_then(|year| NaiveDate::from_ymd_opt(year, naive_date.month0(), naive_date.day0()))
+        .map(|date| date.to_string())
+        .unwrap_or_else(|| naive_date.to_string());
+
+      data_result_ok(DateResultPB { date: formatted })
+    },
+    None => Err(FlowyError::internal().with_context("Failed to parse date from")),
+  }
+}

+ 19 - 0
frontend/rust-lib/flowy-date/src/event_map.rs

@@ -0,0 +1,19 @@
+use strum_macros::Display;
+
+use flowy_derive::{Flowy_Event, ProtoBuf_Enum};
+use lib_dispatch::prelude::AFPlugin;
+
+use crate::event_handler::query_date_handler;
+
+pub fn init() -> AFPlugin {
+  AFPlugin::new()
+    .name(env!("CARGO_PKG_NAME"))
+    .event(DateEvent::QueryDate, query_date_handler)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)]
+#[event_err = "FlowyError"]
+pub enum DateEvent {
+  #[event(input = "DateQueryPB", output = "DateResultPB")]
+  QueryDate = 0,
+}

+ 4 - 0
frontend/rust-lib/flowy-date/src/lib.rs

@@ -0,0 +1,4 @@
+pub mod entities;
+pub mod event_handler;
+pub mod event_map;
+pub mod protobuf;

+ 1 - 0
frontend/rust-lib/flowy-document2/src/lib.rs

@@ -10,3 +10,4 @@ pub mod protobuf;
 pub mod deps;
 pub mod notification;
 mod parse;
+pub mod reminder;

+ 10 - 0
frontend/rust-lib/flowy-document2/src/manager.rs

@@ -17,6 +17,7 @@ use flowy_storage::FileStorageService;
 
 use crate::document::MutexDocument;
 use crate::entities::DocumentSnapshotPB;
+use crate::reminder::DocumentReminderAction;
 
 pub trait DocumentUser: Send + Sync {
   fn user_id(&self) -> Result<i64, FlowyError>;
@@ -58,6 +59,15 @@ impl DocumentManager {
     self.initialize(uid, workspace_id).await?;
     Ok(())
   }
+
+  pub async fn handle_reminder_action(&self, action: DocumentReminderAction) {
+    match action {
+      DocumentReminderAction::Add { reminder: _ } => {},
+      DocumentReminderAction::Remove { reminder_id: _ } => {},
+      DocumentReminderAction::Update { reminder: _ } => {},
+    }
+  }
+
   /// Create a new document.
   ///
   /// if the document already exists, return the existing document.

+ 23 - 0
frontend/rust-lib/flowy-document2/src/reminder.rs

@@ -0,0 +1,23 @@
+use collab_define::reminder::Reminder;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum DocumentReminderAction {
+  Add { reminder: DocumentReminder },
+  Remove { reminder_id: String },
+  Update { reminder: DocumentReminder },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DocumentReminder {
+  document_id: String, // defines the necessary fields for a reminder
+}
+
+impl TryFrom<Reminder> for DocumentReminder {
+  type Error = serde_json::Error;
+
+  fn try_from(value: Reminder) -> Result<Self, Self::Error> {
+    serde_json::from_value(json!(value.meta.into_inner()))
+  }
+}

+ 13 - 8
frontend/rust-lib/flowy-error/Cargo.toml

@@ -7,18 +7,21 @@ edition = "2018"
 
 [dependencies]
 flowy-derive = { path = "../../../shared-lib/flowy-derive" }
-protobuf = {version = "2.28.0"}
+protobuf = { version = "2.28.0" }
 bytes = "1.4"
 anyhow = "1.0"
 thiserror = "1.0"
 
+fancy-regex = { version = "0.11.0" }
 lib-dispatch = { workspace = true, optional = true }
-serde_json = {version = "1.0", optional = true}
+serde_json = { version = "1.0", optional = true }
 serde_repr = { version = "0.1" }
 serde = "1.0"
-reqwest = { version = "0.11.14", optional = true, features = ["native-tls-vendored"] }
-flowy-sqlite = { workspace = true, optional = true}
-r2d2 = { version = "0.8", optional = true}
+reqwest = { version = "0.11.14", optional = true, features = [
+    "native-tls-vendored",
+] }
+flowy-sqlite = { workspace = true, optional = true }
+r2d2 = { version = "0.8", optional = true }
 url = { version = "2.2", optional = true }
 collab-database = { version = "0.1.0", optional = true }
 collab-document = { version = "0.1.0", optional = true }
@@ -33,11 +36,13 @@ impl_from_reqwest = ["reqwest"]
 impl_from_sqlite = ["flowy-sqlite", "r2d2"]
 impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"]
 impl_from_postgres = ["tokio-postgres"]
-impl_from_tokio= ["tokio"]
-impl_from_url= ["url"]
+impl_from_tokio = ["tokio"]
+impl_from_url = ["url"]
 impl_from_appflowy_cloud = ["client-api"]
 dart = ["flowy-codegen/dart"]
 ts = ["flowy-codegen/ts"]
 
 [build-dependencies]
-flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = ["proto_gen"]}
+flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = [
+    "proto_gen",
+] }

+ 6 - 0
frontend/rust-lib/flowy-error/src/errors.rs

@@ -137,3 +137,9 @@ impl From<anyhow::Error> for FlowyError {
       .unwrap_or_else(|err| FlowyError::new(ErrorCode::Internal, err))
   }
 }
+
+impl From<fancy_regex::Error> for FlowyError {
+  fn from(e: fancy_regex::Error) -> Self {
+    FlowyError::internal().with_context(e)
+  }
+}

+ 8 - 2
frontend/rust-lib/flowy-test/tests/user/local_test/user_awareness_test.rs

@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
 use flowy_test::event_builder::EventBuilder;
 use flowy_test::FlowyCoreTest;
 use flowy_user::entities::{ReminderPB, RepeatedReminderPB};
@@ -7,14 +9,18 @@ use flowy_user::event_map::UserEvent::*;
 async fn user_update_with_name() {
   let sdk = FlowyCoreTest::new();
   let _ = sdk.sign_up_as_guest().await;
+  let mut meta = HashMap::new();
+  meta.insert("object_id".to_string(), "".to_string());
+
   let payload = ReminderPB {
     id: "".to_string(),
     scheduled_at: 0,
     is_ack: false,
-    ty: 0,
+    is_read: false,
     title: "".to_string(),
     message: "".to_string(),
-    reminder_object_id: "".to_string(),
+    object_id: "".to_string(),
+    meta,
   };
   let _ = EventBuilder::new(sdk.clone())
     .event(CreateReminder)

+ 7 - 6
frontend/rust-lib/flowy-user/Cargo.toml

@@ -28,16 +28,17 @@ anyhow = "1.0.75"
 tracing = { version = "0.1", features = ["log"] }
 bytes = "1.4"
 serde = { version = "1.0", features = ["derive"] }
-serde_json = {version = "1.0"}
+serde_json = { version = "1.0" }
 serde_repr = "0.1"
 log = "0.4.17"
-protobuf = {version = "2.28.0"}
+protobuf = { version = "2.28.0" }
 lazy_static = "1.4.0"
-diesel = {version = "1.4.8", features = ["sqlite"]}
-diesel_derives = {version = "1.4.1", features = ["sqlite"]}
+diesel = { version = "1.4.8", features = ["sqlite"] }
+diesel_derives = { version = "1.4.1", features = ["sqlite"] }
 once_cell = "1.17.1"
 parking_lot = "0.12.1"
-strum_macros = "0.21"
+strum = "0.25"
+strum_macros = "0.25.2"
 tokio = { version = "1.26", features = ["rt"] }
 validator = "0.16.0"
 unicode-segmentation = "1.10"
@@ -61,4 +62,4 @@ dart = ["flowy-codegen/dart", "flowy-notification/dart"]
 ts = ["flowy-codegen/ts", "flowy-notification/ts"]
 
 [build-dependencies]
-flowy-codegen = { path = "../../../shared-lib/flowy-codegen"}
+flowy-codegen = { path = "../../../shared-lib/flowy-codegen" }

+ 79 - 0
frontend/rust-lib/flowy-user/src/entities/date_time.rs

@@ -0,0 +1,79 @@
+use serde::{Deserialize, Serialize};
+
+use flowy_derive::ProtoBuf_Enum;
+
+#[derive(ProtoBuf_Enum, Serialize, Deserialize, Debug, Clone, Default, Copy)]
+pub enum UserDateFormatPB {
+  Locally = 0,
+  US = 1,
+  ISO = 2,
+  #[default]
+  Friendly = 3,
+  DayMonthYear = 4,
+}
+
+impl std::convert::From<i64> for UserDateFormatPB {
+  fn from(value: i64) -> Self {
+    match value {
+      0 => UserDateFormatPB::Locally,
+      1 => UserDateFormatPB::US,
+      2 => UserDateFormatPB::ISO,
+      3 => UserDateFormatPB::Friendly,
+      4 => UserDateFormatPB::DayMonthYear,
+      _ => {
+        tracing::error!("Unsupported date format, fallback to friendly");
+        UserDateFormatPB::Friendly
+      },
+    }
+  }
+}
+
+impl UserDateFormatPB {
+  pub fn value(&self) -> i64 {
+    *self as i64
+  }
+  // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html
+  pub fn format_str(&self) -> &'static str {
+    match self {
+      UserDateFormatPB::Locally => "%m/%d/%Y",
+      UserDateFormatPB::US => "%Y/%m/%d",
+      UserDateFormatPB::ISO => "%Y-%m-%d",
+      UserDateFormatPB::Friendly => "%b %d, %Y",
+      UserDateFormatPB::DayMonthYear => "%d/%m/%Y",
+    }
+  }
+}
+
+#[derive(ProtoBuf_Enum, Serialize, Deserialize, Debug, Clone, Default, Copy)]
+pub enum UserTimeFormatPB {
+  TwelveHour = 0,
+  #[default]
+  TwentyFourHour = 1,
+}
+
+impl std::convert::From<i64> for UserTimeFormatPB {
+  fn from(value: i64) -> Self {
+    match value {
+      0 => UserTimeFormatPB::TwelveHour,
+      1 => UserTimeFormatPB::TwentyFourHour,
+      _ => {
+        tracing::error!("Unsupported time format, fallback to TwentyFourHour");
+        UserTimeFormatPB::TwentyFourHour
+      },
+    }
+  }
+}
+
+impl UserTimeFormatPB {
+  pub fn value(&self) -> i64 {
+    *self as i64
+  }
+
+  // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html
+  pub fn format_str(&self) -> &'static str {
+    match self {
+      UserTimeFormatPB::TwelveHour => "%I:%M %p",
+      UserTimeFormatPB::TwentyFourHour => "%R",
+    }
+  }
+}

+ 1 - 0
frontend/rust-lib/flowy-user/src/entities/mod.rs

@@ -5,6 +5,7 @@ pub use user_profile::*;
 pub use user_setting::*;
 
 pub mod auth;
+pub mod date_time;
 pub mod parser;
 pub mod realtime;
 mod reminder;

+ 23 - 12
frontend/rust-lib/flowy-user/src/entities/reminder.rs

@@ -1,6 +1,6 @@
-use collab_define::reminder::{ObjectType, Reminder};
-
+use collab_define::reminder::{ObjectType, Reminder, ReminderMeta};
 use flowy_derive::ProtoBuf;
+use std::collections::HashMap;
 
 #[derive(ProtoBuf, Default, Clone)]
 pub struct ReminderPB {
@@ -8,22 +8,25 @@ pub struct ReminderPB {
   pub id: String,
 
   #[pb(index = 2)]
-  pub scheduled_at: i64,
+  pub object_id: String,
 
   #[pb(index = 3)]
-  pub is_ack: bool,
+  pub scheduled_at: i64,
 
   #[pb(index = 4)]
-  pub ty: i64,
+  pub is_ack: bool,
 
   #[pb(index = 5)]
-  pub title: String,
+  pub is_read: bool,
 
   #[pb(index = 6)]
-  pub message: String,
+  pub title: String,
 
   #[pb(index = 7)]
-  pub reminder_object_id: String,
+  pub message: String,
+
+  #[pb(index = 8)]
+  pub meta: HashMap<String, String>,
 }
 
 #[derive(ProtoBuf, Default, Clone)]
@@ -38,11 +41,12 @@ impl From<ReminderPB> for Reminder {
       id: value.id,
       scheduled_at: value.scheduled_at,
       is_ack: value.is_ack,
+      is_read: value.is_read,
       ty: ObjectType::Document,
       title: value.title,
       message: value.message,
-      meta: Default::default(),
-      object_id: value.reminder_object_id,
+      meta: ReminderMeta::from(value.meta),
+      object_id: value.object_id,
     }
   }
 }
@@ -51,12 +55,13 @@ impl From<Reminder> for ReminderPB {
   fn from(value: Reminder) -> Self {
     Self {
       id: value.id,
+      object_id: value.object_id,
       scheduled_at: value.scheduled_at,
       is_ack: value.is_ack,
-      ty: value.ty as i64,
+      is_read: value.is_read,
       title: value.title,
       message: value.message,
-      reminder_object_id: value.object_id,
+      meta: value.meta.into_inner(),
     }
   }
 }
@@ -66,3 +71,9 @@ impl From<Vec<ReminderPB>> for RepeatedReminderPB {
     Self { items: value }
   }
 }
+
+#[derive(ProtoBuf, Default, Clone)]
+pub struct ReminderIdentifierPB {
+  #[pb(index = 1)]
+  pub id: String,
+}

+ 28 - 1
frontend/rust-lib/flowy-user/src/entities/user_setting.rs

@@ -7,6 +7,8 @@ use flowy_user_deps::cloud::UserCloudConfig;
 
 use crate::entities::EncryptionTypePB;
 
+use super::date_time::{UserDateFormatPB, UserTimeFormatPB};
+
 #[derive(ProtoBuf, Default, Debug, Clone)]
 pub struct UserPreferencesPB {
   #[pb(index = 1)]
@@ -14,6 +16,9 @@ pub struct UserPreferencesPB {
 
   #[pb(index = 2)]
   appearance_setting: AppearanceSettingsPB,
+
+  #[pb(index = 3)]
+  date_time_settings: DateTimeSettingsPB,
 }
 
 #[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)]
@@ -106,7 +111,7 @@ impl std::default::Default for LocaleSettingsPB {
   }
 }
 
-pub const APPEARANCE_DEFAULT_THEME: &str = "light";
+pub const APPEARANCE_DEFAULT_THEME: &str = "Default";
 pub const APPEARANCE_DEFAULT_FONT: &str = "Poppins";
 pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono";
 const APPEARANCE_RESET_AS_DEFAULT: bool = true;
@@ -210,3 +215,25 @@ pub struct NetworkStatePB {
   #[pb(index = 1)]
   pub ty: NetworkTypePB,
 }
+
+#[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)]
+pub struct DateTimeSettingsPB {
+  #[pb(index = 1)]
+  pub date_format: UserDateFormatPB,
+
+  #[pb(index = 2)]
+  pub time_format: UserTimeFormatPB,
+
+  #[pb(index = 3)]
+  pub timezone_id: String,
+}
+
+impl std::default::Default for DateTimeSettingsPB {
+  fn default() -> Self {
+    DateTimeSettingsPB {
+      date_format: UserDateFormatPB::Friendly,
+      time_format: UserTimeFormatPB::TwentyFourHour,
+      timezone_id: "".to_owned(),
+    }
+  }
+}

+ 64 - 0
frontend/rust-lib/flowy-user/src/event_handler.rs

@@ -166,6 +166,46 @@ pub async fn get_appearance_setting(
   }
 }
 
+const DATE_TIME_SETTINGS_CACHE_KEY: &str = "date_time_settings";
+
+#[tracing::instrument(level = "debug", skip_all, err)]
+pub async fn set_date_time_settings(
+  store_preferences: AFPluginState<Weak<StorePreferences>>,
+  data: AFPluginData<DateTimeSettingsPB>,
+) -> Result<(), FlowyError> {
+  let store_preferences = upgrade_store_preferences(store_preferences)?;
+  let mut setting = data.into_inner();
+  if setting.timezone_id.is_empty() {
+    setting.timezone_id = "".to_string();
+  }
+
+  store_preferences.set_object(DATE_TIME_SETTINGS_CACHE_KEY, setting)?;
+  Ok(())
+}
+
+#[tracing::instrument(level = "debug", skip_all, err)]
+pub async fn get_date_time_settings(
+  store_preferences: AFPluginState<Weak<StorePreferences>>,
+) -> DataResult<DateTimeSettingsPB, FlowyError> {
+  let store_preferences = upgrade_store_preferences(store_preferences)?;
+  match store_preferences.get_str(DATE_TIME_SETTINGS_CACHE_KEY) {
+    None => data_result_ok(DateTimeSettingsPB::default()),
+    Some(s) => {
+      let setting = match serde_json::from_str(&s) {
+        Ok(setting) => setting,
+        Err(e) => {
+          tracing::error!(
+            "Deserialize AppearanceSettings failed: {:?}, fallback to default",
+            e
+          );
+          DateTimeSettingsPB::default()
+        },
+      };
+      data_result_ok(setting)
+    },
+  }
+}
+
 #[tracing::instrument(level = "debug", skip_all, err)]
 pub async fn get_user_setting(
   manager: AFPluginState<Weak<UserManager>>,
@@ -457,3 +497,27 @@ pub async fn reset_workspace_handler(
   manager.reset_workspace(reset_pb, session.device_id).await?;
   Ok(())
 }
+
+#[tracing::instrument(level = "debug", skip_all, err)]
+pub async fn remove_reminder_event_handler(
+  data: AFPluginData<ReminderIdentifierPB>,
+  manager: AFPluginState<Weak<UserManager>>,
+) -> Result<(), FlowyError> {
+  let manager = upgrade_manager(manager)?;
+
+  let params = data.into_inner();
+  let _ = manager.remove_reminder(params.id.as_str()).await;
+
+  Ok(())
+}
+
+#[tracing::instrument(level = "debug", skip_all, err)]
+pub async fn update_reminder_event_handler(
+  data: AFPluginData<ReminderPB>,
+  manager: AFPluginState<Weak<UserManager>>,
+) -> Result<(), FlowyError> {
+  let manager = upgrade_manager(manager)?;
+  let params = data.into_inner();
+  manager.update_reminder(params).await?;
+  Ok(())
+}

+ 22 - 3
frontend/rust-lib/flowy-user/src/event_map.rs

@@ -54,7 +54,11 @@ pub fn init(user_session: Weak<UserManager>) -> AFPlugin {
     .event(UserEvent::PushRealtimeEvent, push_realtime_event_handler)
     .event(UserEvent::CreateReminder, create_reminder_event_handler)
     .event(UserEvent::GetAllReminders, get_all_reminder_event_handler)
+    .event(UserEvent::RemoveReminder, remove_reminder_event_handler)
+    .event(UserEvent::UpdateReminder, update_reminder_event_handler)
     .event(UserEvent::ResetWorkspace, reset_workspace_handler)
+    .event(UserEvent::SetDateTimeSettings, set_date_time_settings)
+    .event(UserEvent::GetDateTimeSettings, get_date_time_settings)
 }
 
 pub struct SignUpContext {
@@ -262,8 +266,9 @@ pub enum UserEvent {
   #[event(input = "HistoricalUserPB")]
   OpenHistoricalUser = 26,
 
-  /// Push a realtime event to the user. Currently, the realtime event is only used
-  /// when the auth type is: [AuthType::Supabase].
+  /// Push a realtime event to the user. Currently, the realtime event
+  /// is only used when the auth type is: [AuthType::Supabase].
+  ///
   #[event(input = "RealtimePayloadPB")]
   PushRealtimeEvent = 27,
 
@@ -273,6 +278,20 @@ pub enum UserEvent {
   #[event(output = "RepeatedReminderPB")]
   GetAllReminders = 29,
 
+  #[event(input = "ReminderIdentifierPB")]
+  RemoveReminder = 30,
+
+  #[event(input = "ReminderPB")]
+  UpdateReminder = 31,
+
   #[event(input = "ResetWorkspacePB")]
-  ResetWorkspace = 30,
+  ResetWorkspace = 32,
+
+  /// Change the Date/Time formats globally
+  #[event(input = "DateTimeSettingsPB")]
+  SetDateTimeSettings = 33,
+
+  /// Retrieve the Date/Time formats
+  #[event(output = "DateTimeSettingsPB")]
+  GetDateTimeSettings = 34,
 }

+ 9 - 1
frontend/rust-lib/flowy-user/src/manager.rs

@@ -25,6 +25,7 @@ use crate::migrations::migration::UserLocalDataMigration;
 use crate::migrations::sync_new_user::sync_user_data_to_cloud;
 use crate::migrations::MigrationUser;
 use crate::services::cloud_config::get_cloud_config;
+use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract};
 use crate::services::database::UserDB;
 use crate::services::entities::{ResumableSignUp, Session};
 use crate::services::user_awareness::UserAwarenessDataSource;
@@ -59,6 +60,7 @@ pub struct UserManager {
   pub(crate) user_awareness: Arc<Mutex<Option<MutexUserAwareness>>>,
   pub(crate) user_status_callback: RwLock<Arc<dyn UserStatusCallback>>,
   pub(crate) collab_builder: Weak<AppFlowyCollabBuilder>,
+  pub(crate) collab_interact: RwLock<Arc<dyn CollabInteract>>,
   resumable_sign_up: Mutex<Option<ResumableSignUp>>,
   current_session: parking_lot::RwLock<Option<Session>>,
 }
@@ -82,6 +84,7 @@ impl UserManager {
       user_awareness: Arc::new(Default::default()),
       user_status_callback,
       collab_builder,
+      collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)),
       resumable_sign_up: Default::default(),
       current_session: Default::default(),
     });
@@ -114,7 +117,11 @@ impl UserManager {
   /// it will attempt a local data migration for the user. After ensuring the user's data is migrated and up-to-date,
   /// the function will set up the collaboration configuration and initialize the user's awareness. Upon successful
   /// completion, a user status callback is invoked to signify that the initialization process is complete.
-  pub async fn init<C: UserStatusCallback + 'static>(&self, user_status_callback: C) {
+  pub async fn init<C: UserStatusCallback + 'static, I: CollabInteract>(
+    &self,
+    user_status_callback: C,
+    collab_interact: I,
+  ) {
     if let Ok(session) = self.get_session() {
       // Do the user data migration if needed
       match (
@@ -155,6 +162,7 @@ impl UserManager {
       }
     }
     *self.user_status_callback.write().await = Arc::new(user_status_callback);
+    *self.collab_interact.write().await = Arc::new(collab_interact);
   }
 
   pub fn db_connection(&self, uid: i64) -> Result<DBConnection, FlowyError> {

+ 25 - 0
frontend/rust-lib/flowy-user/src/services/collab_interact.rs

@@ -0,0 +1,25 @@
+use anyhow::Error;
+use collab_define::reminder::Reminder;
+
+use lib_infra::future::FutureResult;
+
+pub trait CollabInteract: Send + Sync + 'static {
+  fn add_reminder(&self, reminder: Reminder) -> FutureResult<(), Error>;
+  fn remove_reminder(&self, reminder_id: &str) -> FutureResult<(), Error>;
+  fn update_reminder(&self, reminder: Reminder) -> FutureResult<(), Error>;
+}
+
+pub struct DefaultCollabInteract;
+impl CollabInteract for DefaultCollabInteract {
+  fn add_reminder(&self, _reminder: Reminder) -> FutureResult<(), Error> {
+    FutureResult::new(async { Ok(()) })
+  }
+
+  fn remove_reminder(&self, _reminder_id: &str) -> FutureResult<(), Error> {
+    FutureResult::new(async { Ok(()) })
+  }
+
+  fn update_reminder(&self, _reminder: Reminder) -> FutureResult<(), Error> {
+    FutureResult::new(async { Ok(()) })
+  }
+}

+ 1 - 0
frontend/rust-lib/flowy-user/src/services/mod.rs

@@ -1,4 +1,5 @@
 pub mod cloud_config;
+pub mod collab_interact;
 pub mod database;
 pub mod entities;
 pub(crate) mod historical_user;

+ 45 - 1
frontend/rust-lib/flowy-user/src/services/user_awareness.rs

@@ -30,9 +30,53 @@ impl UserManager {
     let reminder = Reminder::from(reminder_pb);
     self
       .with_awareness((), |user_awareness| {
-        user_awareness.add_reminder(reminder);
+        user_awareness.add_reminder(reminder.clone());
       })
       .await;
+    self
+      .collab_interact
+      .read()
+      .await
+      .add_reminder(reminder)
+      .await?;
+    Ok(())
+  }
+
+  /// Removes a specific reminder for the user by its id
+  ///
+  pub async fn remove_reminder(&self, reminder_id: &str) -> FlowyResult<()> {
+    self
+      .with_awareness((), |user_awareness| {
+        user_awareness.remove_reminder(reminder_id);
+      })
+      .await;
+    self
+      .collab_interact
+      .read()
+      .await
+      .remove_reminder(reminder_id)
+      .await?;
+    Ok(())
+  }
+
+  /// Updates an existing reminder
+  ///
+  pub async fn update_reminder(&self, reminder_pb: ReminderPB) -> FlowyResult<()> {
+    let reminder = Reminder::from(reminder_pb);
+    self
+      .with_awareness((), |user_awareness| {
+        user_awareness.update_reminder(&reminder.id, |new_reminder| {
+          new_reminder.clone_from(&reminder)
+        });
+      })
+      .await;
+    self
+      .collab_interact
+      .read()
+      .await
+      .update_reminder(reminder)
+      .await?;
+
     Ok(())
   }
 

+ 0 - 1
shared-lib/lib-infra/src/lib.rs

@@ -3,5 +3,4 @@ pub use async_trait;
 pub mod box_any;
 pub mod future;
 pub mod ref_map;
-pub mod retry;
 pub mod util;

+ 0 - 218
shared-lib/lib-infra/src/retry/future.rs

@@ -1,218 +0,0 @@
-#![allow(clippy::large_enum_variant)]
-#![allow(clippy::type_complexity)]
-use crate::retry::FixedInterval;
-use pin_project::pin_project;
-use std::{
-  future::Future,
-  iter::{IntoIterator, Iterator},
-  pin::Pin,
-  task::{Context, Poll},
-};
-use tokio::time::{sleep_until, Duration, Instant, Sleep};
-
-#[pin_project(project = RetryStateProj)]
-enum RetryState<A>
-where
-  A: Action,
-{
-  Running(#[pin] A::Future),
-  Sleeping(#[pin] Sleep),
-}
-
-impl<A: Action> RetryState<A> {
-  fn poll(self: Pin<&mut Self>, cx: &mut Context) -> RetryFuturePoll<A> {
-    match self.project() {
-      RetryStateProj::Running(future) => RetryFuturePoll::Running(future.poll(cx)),
-      RetryStateProj::Sleeping(future) => RetryFuturePoll::Sleeping(future.poll(cx)),
-    }
-  }
-}
-
-enum RetryFuturePoll<A>
-where
-  A: Action,
-{
-  Running(Poll<Result<A::Item, A::Error>>),
-  Sleeping(Poll<()>),
-}
-
-/// Future that drives multiple attempts at an action via a retry strategy.
-#[pin_project]
-pub struct Retry<I, A>
-where
-  I: Iterator<Item = Duration>,
-  A: Action,
-{
-  #[pin]
-  retry_if: RetryIf<I, A, fn(&A::Error) -> bool>,
-}
-
-impl<I, A> Retry<I, A>
-where
-  I: Iterator<Item = Duration>,
-  A: Action,
-{
-  pub fn new<T: IntoIterator<IntoIter = I, Item = Duration>>(
-    strategy: T,
-    action: A,
-  ) -> Retry<I, A> {
-    Retry {
-      retry_if: RetryIf::spawn(strategy, action, (|_| true) as fn(&A::Error) -> bool),
-    }
-  }
-}
-
-impl<I, A> Future for Retry<I, A>
-where
-  I: Iterator<Item = Duration>,
-  A: Action,
-{
-  type Output = Result<A::Item, A::Error>;
-
-  fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
-    let this = self.project();
-    this.retry_if.poll(cx)
-  }
-}
-
-/// Future that drives multiple attempts at an action via a retry strategy.
-/// Retries are only attempted if the `Error` returned by the future satisfies a
-/// given condition.
-#[pin_project]
-pub struct RetryIf<I, A, C>
-where
-  I: Iterator<Item = Duration>,
-  A: Action,
-  C: Condition<A::Error>,
-{
-  strategy: I,
-  #[pin]
-  state: RetryState<A>,
-  action: A,
-  condition: C,
-}
-
-impl<I, A, C> RetryIf<I, A, C>
-where
-  I: Iterator<Item = Duration>,
-  A: Action,
-  C: Condition<A::Error>,
-{
-  pub fn spawn<T: IntoIterator<IntoIter = I, Item = Duration>>(
-    strategy: T,
-    mut action: A,
-    condition: C,
-  ) -> RetryIf<I, A, C> {
-    RetryIf {
-      strategy: strategy.into_iter(),
-      state: RetryState::Running(action.run()),
-      action,
-      condition,
-    }
-  }
-
-  fn attempt(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Result<A::Item, A::Error>> {
-    let future = {
-      let this = self.as_mut().project();
-      this.action.run()
-    };
-    self
-      .as_mut()
-      .project()
-      .state
-      .set(RetryState::Running(future));
-    self.poll(cx)
-  }
-
-  fn retry(
-    mut self: Pin<&mut Self>,
-    err: A::Error,
-    cx: &mut Context,
-  ) -> Result<Poll<Result<A::Item, A::Error>>, A::Error> {
-    match self.as_mut().project().strategy.next() {
-      None => Err(err),
-      Some(duration) => {
-        let deadline = Instant::now() + duration;
-        let future = sleep_until(deadline);
-        self
-          .as_mut()
-          .project()
-          .state
-          .set(RetryState::Sleeping(future));
-        Ok(self.poll(cx))
-      },
-    }
-  }
-}
-
-impl<I, A, C> Future for RetryIf<I, A, C>
-where
-  I: Iterator<Item = Duration>,
-  A: Action,
-  C: Condition<A::Error>,
-{
-  type Output = Result<A::Item, A::Error>;
-
-  fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
-    match self.as_mut().project().state.poll(cx) {
-      RetryFuturePoll::Running(poll_result) => match poll_result {
-        Poll::Ready(Ok(ok)) => Poll::Ready(Ok(ok)),
-        Poll::Pending => Poll::Pending,
-        Poll::Ready(Err(err)) => {
-          if self.as_mut().project().condition.should_retry(&err) {
-            match self.retry(err, cx) {
-              Ok(poll) => poll,
-              Err(err) => Poll::Ready(Err(err)),
-            }
-          } else {
-            Poll::Ready(Err(err))
-          }
-        },
-      },
-      RetryFuturePoll::Sleeping(poll_result) => match poll_result {
-        Poll::Pending => Poll::Pending,
-        Poll::Ready(_) => self.attempt(cx),
-      },
-    }
-  }
-}
-
-/// An action can be run multiple times and produces a future.
-pub trait Action: Send + Sync {
-  type Future: Future<Output = Result<Self::Item, Self::Error>>;
-  type Item;
-  type Error;
-
-  fn run(&mut self) -> Self::Future;
-}
-// impl<R, E, T: Future<Output = Result<R, E>>, F: FnMut() -> T + Send + Sync>
-// Action for F {     type Future = T;
-//     type Item = R;
-//     type Error = E;
-//
-//     fn run(&mut self) -> Self::Future { self() }
-// }
-
-pub trait Condition<E> {
-  fn should_retry(&mut self, error: &E) -> bool;
-}
-
-impl<E, F: FnMut(&E) -> bool> Condition<E> for F {
-  fn should_retry(&mut self, error: &E) -> bool {
-    self(error)
-  }
-}
-
-pub fn spawn_retry<A: Action + 'static>(
-  retry_count: usize,
-  retry_per_millis: u64,
-  action: A,
-) -> impl Future<Output = Result<A::Item, A::Error>>
-where
-  A::Item: Send + Sync,
-  A::Error: Send + Sync,
-  <A as Action>::Future: Send + Sync,
-{
-  let strategy = FixedInterval::from_millis(retry_per_millis).take(retry_count);
-  Retry::new(strategy, action)
-}

+ 0 - 5
shared-lib/lib-infra/src/retry/mod.rs

@@ -1,5 +0,0 @@
-mod future;
-mod strategy;
-
-pub use future::*;
-pub use strategy::*;

+ 0 - 127
shared-lib/lib-infra/src/retry/strategy/exponential_backoff.rs

@@ -1,127 +0,0 @@
-use std::{iter::Iterator, time::Duration};
-/// A retry strategy driven by exponential back-off.
-///
-/// The power corresponds to the number of past attempts.
-#[derive(Debug, Clone)]
-pub struct ExponentialBackoff {
-  current: u64,
-  base: u64,
-  factor: u64,
-  max_delay: Option<Duration>,
-}
-
-impl ExponentialBackoff {
-  /// Constructs a new exponential back-off strategy,
-  /// given a base duration in milliseconds.
-  ///
-  /// The resulting duration is calculated by taking the base to the `n`-th
-  /// power, where `n` denotes the number of past attempts.
-  pub fn from_millis(base: u64) -> ExponentialBackoff {
-    ExponentialBackoff {
-      current: base,
-      base,
-      factor: 1u64,
-      max_delay: None,
-    }
-  }
-
-  /// A multiplicative factor that will be applied to the retry delay.
-  ///
-  /// For example, using a factor of `1000` will make each delay in units of
-  /// seconds.
-  ///
-  /// Default factor is `1`.
-  pub fn factor(mut self, factor: u64) -> ExponentialBackoff {
-    self.factor = factor;
-    self
-  }
-
-  /// Apply a maximum delay. No retry delay will be longer than this
-  /// `Duration`.
-  pub fn max_delay(mut self, duration: Duration) -> ExponentialBackoff {
-    self.max_delay = Some(duration);
-    self
-  }
-}
-
-impl Iterator for ExponentialBackoff {
-  type Item = Duration;
-
-  fn next(&mut self) -> Option<Duration> {
-    // set delay duration by applying factor
-    let duration = if let Some(duration) = self.current.checked_mul(self.factor) {
-      Duration::from_millis(duration)
-    } else {
-      Duration::from_millis(u64::MAX)
-    };
-
-    // check if we reached max delay
-    if let Some(ref max_delay) = self.max_delay {
-      if duration > *max_delay {
-        return Some(*max_delay);
-      }
-    }
-
-    if let Some(next) = self.current.checked_mul(self.base) {
-      self.current = next;
-    } else {
-      self.current = u64::MAX;
-    }
-
-    Some(duration)
-  }
-}
-
-#[test]
-fn returns_some_exponential_base_10() {
-  let mut s = ExponentialBackoff::from_millis(10);
-
-  assert_eq!(s.next(), Some(Duration::from_millis(10)));
-  assert_eq!(s.next(), Some(Duration::from_millis(100)));
-  assert_eq!(s.next(), Some(Duration::from_millis(1000)));
-}
-
-#[test]
-fn returns_some_exponential_base_2() {
-  let mut s = ExponentialBackoff::from_millis(2);
-
-  assert_eq!(s.next(), Some(Duration::from_millis(2)));
-  assert_eq!(s.next(), Some(Duration::from_millis(4)));
-  assert_eq!(s.next(), Some(Duration::from_millis(8)));
-}
-
-#[test]
-fn saturates_at_maximum_value() {
-  let mut s = ExponentialBackoff::from_millis(u64::MAX - 1);
-
-  assert_eq!(s.next(), Some(Duration::from_millis(u64::MAX - 1)));
-  assert_eq!(s.next(), Some(Duration::from_millis(u64::MAX)));
-  assert_eq!(s.next(), Some(Duration::from_millis(u64::MAX)));
-}
-
-#[test]
-fn can_use_factor_to_get_seconds() {
-  let factor = 1000;
-  let mut s = ExponentialBackoff::from_millis(2).factor(factor);
-
-  assert_eq!(s.next(), Some(Duration::from_secs(2)));
-  assert_eq!(s.next(), Some(Duration::from_secs(4)));
-  assert_eq!(s.next(), Some(Duration::from_secs(8)));
-}
-
-#[test]
-fn stops_increasing_at_max_delay() {
-  let mut s = ExponentialBackoff::from_millis(2).max_delay(Duration::from_millis(4));
-
-  assert_eq!(s.next(), Some(Duration::from_millis(2)));
-  assert_eq!(s.next(), Some(Duration::from_millis(4)));
-  assert_eq!(s.next(), Some(Duration::from_millis(4)));
-}
-
-#[test]
-fn returns_max_when_max_less_than_base() {
-  let mut s = ExponentialBackoff::from_millis(20).max_delay(Duration::from_millis(10));
-
-  assert_eq!(s.next(), Some(Duration::from_millis(10)));
-  assert_eq!(s.next(), Some(Duration::from_millis(10)));
-}

+ 0 - 39
shared-lib/lib-infra/src/retry/strategy/fixed_interval.rs

@@ -1,39 +0,0 @@
-use std::{iter::Iterator, time::Duration};
-
-/// A retry strategy driven by a fixed interval.
-#[derive(Debug, Clone)]
-pub struct FixedInterval {
-  duration: Duration,
-}
-
-impl FixedInterval {
-  /// Constructs a new fixed interval strategy.
-  pub fn new(duration: Duration) -> FixedInterval {
-    FixedInterval { duration }
-  }
-
-  /// Constructs a new fixed interval strategy,
-  /// given a duration in milliseconds.
-  pub fn from_millis(millis: u64) -> FixedInterval {
-    FixedInterval {
-      duration: Duration::from_millis(millis),
-    }
-  }
-}
-
-impl Iterator for FixedInterval {
-  type Item = Duration;
-
-  fn next(&mut self) -> Option<Duration> {
-    Some(self.duration)
-  }
-}
-
-#[test]
-fn returns_some_fixed() {
-  let mut s = FixedInterval::new(Duration::from_millis(123));
-
-  assert_eq!(s.next(), Some(Duration::from_millis(123)));
-  assert_eq!(s.next(), Some(Duration::from_millis(123)));
-  assert_eq!(s.next(), Some(Duration::from_millis(123)));
-}

+ 0 - 5
shared-lib/lib-infra/src/retry/strategy/jitter.rs

@@ -1,5 +0,0 @@
-use std::time::Duration;
-
-pub fn jitter(duration: Duration) -> Duration {
-  duration.mul_f64(rand::random::<f64>())
-}

+ 0 - 7
shared-lib/lib-infra/src/retry/strategy/mod.rs

@@ -1,7 +0,0 @@
-mod exponential_backoff;
-mod fixed_interval;
-mod jitter;
-
-pub use exponential_backoff::*;
-pub use fixed_interval::*;
-pub use jitter::*;