Browse Source

feat: reminder improvements (#3658)

Mathias Mogensen 1 year ago
parent
commit
bc8f35d7db
26 changed files with 785 additions and 154 deletions
  1. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart
  2. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart
  3. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart
  4. 2 4
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart
  5. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart
  6. 65 0
      frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart
  7. 52 37
      frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart
  8. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart
  9. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart
  10. 2 2
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart
  11. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart
  12. 2 2
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart
  13. 396 75
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart
  14. 58 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_group.dart
  15. 65 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_grouped_view.dart
  16. 18 10
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart
  17. 59 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_view.dart
  18. 20 0
      frontend/appflowy_flutter/lib/workspace/presentation/notifications/notifications_hub_empty.dart
  19. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart
  20. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart
  21. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart
  22. 2 2
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart
  23. 1 1
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart
  24. 1 1
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart
  25. 20 9
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart
  26. 12 1
      frontend/resources/translations/en.json

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart

@@ -67,7 +67,7 @@ class _PrimaryCellAccessoryState extends State<PrimaryCellAccessory>
     with GridCellAccessoryState {
   @override
   Widget build(BuildContext context) {
-    return FlowyTooltip.delayed(
+    return FlowyTooltip(
       message: LocaleKeys.tooltip_openAsPage.tr(),
       child: SizedBox(
         width: 26,

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart

@@ -21,7 +21,7 @@ class BlockActionButton extends StatelessWidget {
   Widget build(BuildContext context) {
     return Align(
       alignment: Alignment.center,
-      child: FlowyTooltip.delayed(
+      child: FlowyTooltip(
         preferBelow: false,
         richMessage: richMessage,
         child: MouseRegion(

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

@@ -156,7 +156,7 @@ class _AlignButton extends StatelessWidget {
       cursor: SystemMouseCursors.click,
       child: GestureDetector(
         onTap: onTap,
-        child: FlowyTooltip.delayed(
+        child: FlowyTooltip(
           message: tooltips,
           child: FlowySvg(
             icon,

+ 2 - 4
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart

@@ -225,11 +225,9 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
             crossAxisAlignment: CrossAxisAlignment.stretch,
             children: children,
           );
-        } else {
-          return const Center(
-            child: CircularProgressIndicator(),
-          );
         }
+
+        return const Center(child: CircularProgressIndicator());
       },
       future: items,
     );

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart

@@ -29,7 +29,7 @@ class DocumentMoreButton extends StatelessWidget {
           ),
         ];
       },
-      child: FlowyTooltip.delayed(
+      child: FlowyTooltip(
         message: LocaleKeys.moreAction_moreOptions.tr(),
         child: FlowySvg(
           FlowySvgs.details_s,

+ 65 - 0
frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart

@@ -0,0 +1,65 @@
+import 'package:bloc/bloc.dart';
+import 'package:equatable/equatable.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'notification_filter_bloc.freezed.dart';
+
+class NotificationFilterBloc
+    extends Bloc<NotificationFilterEvent, NotificationFilterState> {
+  NotificationFilterBloc() : super(const NotificationFilterState()) {
+    on<NotificationFilterEvent>((event, emit) async {
+      event.when(
+        reset: () => emit(const NotificationFilterState()),
+        changeSortBy: (NotificationSortOption sortBy) => emit(
+          state.copyWith(sortBy: sortBy),
+        ),
+        toggleGroupByDate: () => emit(
+          state.copyWith(groupByDate: !state.groupByDate),
+        ),
+        toggleShowUnreadsOnly: () => emit(
+          state.copyWith(showUnreadsOnly: !state.showUnreadsOnly),
+        ),
+      );
+    });
+  }
+}
+
+enum NotificationSortOption {
+  descending,
+  ascending,
+}
+
+@freezed
+class NotificationFilterEvent with _$NotificationFilterEvent {
+  const factory NotificationFilterEvent.toggleShowUnreadsOnly() =
+      _ToggleShowUnreadsOnly;
+
+  const factory NotificationFilterEvent.toggleGroupByDate() =
+      _ToggleGroupByDate;
+
+  const factory NotificationFilterEvent.changeSortBy(
+    NotificationSortOption sortBy,
+  ) = _ChangeSortBy;
+
+  const factory NotificationFilterEvent.reset() = _Reset;
+}
+
+@freezed
+class NotificationFilterState extends Equatable with _$NotificationFilterState {
+  const NotificationFilterState._();
+
+  const factory NotificationFilterState({
+    @Default(false) bool showUnreadsOnly,
+    @Default(false) bool groupByDate,
+    @Default(NotificationSortOption.descending) NotificationSortOption sortBy,
+  }) = _NotificationFilterState;
+
+  // If state is not default values, then there are custom changes
+  bool get hasFilters =>
+      showUnreadsOnly != false ||
+      groupByDate != false ||
+      sortBy != NotificationSortOption.descending;
+
+  @override
+  List<Object?> get props => [showUnreadsOnly, groupByDate, sortBy];
+}

+ 52 - 37
frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart

@@ -34,19 +34,19 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
 
           remindersOrFailure.fold(
             (error) => Log.error(error),
-            (reminders) => _updateState(emit, reminders),
+            (reminders) => emit(state.copyWith(reminders: reminders)),
           );
         },
-        remove: (reminderId) async {
+        remove: (reminder) async {
           final unitOrFailure =
-              await reminderService.removeReminder(reminderId: reminderId);
+              await reminderService.removeReminder(reminderId: reminder.id);
 
           unitOrFailure.fold(
             (error) => Log.error(error),
             (_) {
               final reminders = [...state.reminders];
-              reminders.removeWhere((e) => e.id == reminderId);
-              _updateState(emit, reminders);
+              reminders.removeWhere((e) => e.id == reminder.id);
+              emit(state.copyWith(reminders: reminders));
             },
           );
         },
@@ -57,8 +57,8 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
           return unitOrFailure.fold(
             (error) => Log.error(error),
             (_) {
-              state.reminders.add(reminder);
-              _updateState(emit, state.reminders);
+              final reminders = [...state.reminders, reminder];
+              emit(state.copyWith(reminders: reminders));
             },
           );
         },
@@ -82,7 +82,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
                   state.reminders.indexWhere((r) => r.id == reminder.id);
               final reminders = [...state.reminders];
               reminders.replaceRange(index, index + 1, [newReminder]);
-              _updateState(emit, reminders);
+              emit(state.copyWith(reminders: reminders));
             },
           );
         },
@@ -108,23 +108,13 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
     });
   }
 
-  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) {
+
+        for (final reminder in state.upcomingReminders) {
           if (reminder.isAck) {
             continue;
           }
@@ -163,7 +153,7 @@ class ReminderEvent with _$ReminderEvent {
   const factory ReminderEvent.started() = _Started;
 
   // Remove a reminder
-  const factory ReminderEvent.remove({required String reminderId}) = _Remove;
+  const factory ReminderEvent.remove({required ReminderPB reminder}) = _Remove;
 
   // Add a reminder
   const factory ReminderEvent.add({required ReminderPB reminder}) = _Add;
@@ -212,21 +202,46 @@ class ReminderUpdate {
 }
 
 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,
+  ReminderState({List<ReminderPB>? reminders}) {
+    _reminders = reminders ?? [];
+
+    pastReminders = [];
+    upcomingReminders = [];
+
+    if (_reminders.isEmpty) {
+      hasUnreads = false;
+      return;
+    }
+
+    final now = DateTime.now();
+
+    bool hasUnreadReminders = false;
+    for (final reminder in _reminders) {
+      final scheduledDate = DateTime.fromMillisecondsSinceEpoch(
+        reminder.scheduledAt.toInt() * 1000,
       );
+
+      if (scheduledDate.isBefore(now)) {
+        pastReminders.add(reminder);
+
+        if (!hasUnreadReminders && !reminder.isRead) {
+          hasUnreadReminders = true;
+        }
+      } else {
+        upcomingReminders.add(reminder);
+      }
+    }
+
+    hasUnreads = hasUnreadReminders;
+  }
+
+  late final List<ReminderPB> _reminders;
+  List<ReminderPB> get reminders => _reminders;
+
+  late final List<ReminderPB> pastReminders;
+  late final List<ReminderPB> upcomingReminders;
+  late final bool hasUnreads;
+
+  ReminderState copyWith({List<ReminderPB>? reminders}) =>
+      ReminderState(reminders: reminders ?? _reminders);
 }

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart

@@ -70,7 +70,7 @@ class SidebarTopMenu extends StatelessWidget {
         ),
       ],
     );
-    return FlowyTooltip.delayed(
+    return FlowyTooltip(
       richMessage: textSpan,
       child: FlowyIconButton(
         width: 28,

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

@@ -66,7 +66,7 @@ class SidebarUser extends StatelessWidget {
 
   Widget _buildSettingsButton(BuildContext context, MenuUserState state) {
     final userProfile = state.userProfile;
-    return FlowyTooltip.delayed(
+    return FlowyTooltip(
       message: LocaleKeys.settings_menu_open.tr(),
       child: IconButton(
         onPressed: () {

+ 2 - 2
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart

@@ -339,7 +339,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
 
   // + button
   Widget _buildViewAddButton(BuildContext context) {
-    return FlowyTooltip.delayed(
+    return FlowyTooltip(
       message: LocaleKeys.menuAppHeader_addPageTooltip.tr(),
       child: ViewAddButton(
         parentViewId: widget.view.id,
@@ -379,7 +379,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
 
   // ··· more action button
   Widget _buildViewMoreActionButton(BuildContext context) {
-    return FlowyTooltip.delayed(
+    return FlowyTooltip(
       message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(),
       child: ViewMoreActionButton(
         view: widget.view,

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

@@ -66,7 +66,7 @@ class FlowyNavigation extends StatelessWidget {
         if (state.isMenuCollapsed) {
           return RotationTransition(
             turns: const AlwaysStoppedAnimation(180 / 360),
-            child: FlowyTooltip.delayed(
+            child: FlowyTooltip(
               richMessage: sidebarTooltipTextSpan(
                 context,
                 LocaleKeys.sideBar_openSidebar.tr(),

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

@@ -24,14 +24,14 @@ class NotificationButton extends StatelessWidget {
     return BlocProvider<ReminderBloc>.value(
       value: getIt<ReminderBloc>(),
       child: BlocBuilder<ReminderBloc, ReminderState>(
-        builder: (context, state) => FlowyTooltip.delayed(
+        builder: (context, state) => FlowyTooltip(
           message: LocaleKeys.notificationHub_title.tr(),
           child: MouseRegion(
             cursor: SystemMouseCursors.click,
             child: AppFlowyPopover(
               mutex: mutex,
               direction: PopoverDirection.bottomWithLeftAligned,
-              constraints: const BoxConstraints(maxHeight: 250, maxWidth: 300),
+              constraints: const BoxConstraints(maxHeight: 250, maxWidth: 350),
               popupBuilder: (_) =>
                   NotificationDialog(views: views, mutex: mutex),
               child: _buildNotificationIcon(context, state.hasUnreads),

+ 396 - 75
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart

@@ -1,25 +1,35 @@
+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/notification_filter/notification_filter_bloc.dart';
 import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
-import 'package:appflowy/workspace/presentation/notifications/notification_item.dart';
+import 'package:appflowy/workspace/presentation/notifications/notification_grouped_view.dart';
+import 'package:appflowy/workspace/presentation/notifications/notification_view.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-folder2/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:calendar_view/calendar_view.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:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/hover.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);
+extension _ReminderSort on Iterable<ReminderPB> {
+  List<ReminderPB> sortByScheduledAt({
+    bool isDescending = true,
+  }) =>
+      sorted(
+        (a, b) => isDescending
+            ? b.scheduledAt.compareTo(a.scheduledAt)
+            : a.scheduledAt.compareTo(b.scheduledAt),
+      );
 }
 
-class NotificationDialog extends StatelessWidget {
+class NotificationDialog extends StatefulWidget {
   const NotificationDialog({
     super.key,
     required this.views,
@@ -29,91 +39,218 @@ class NotificationDialog extends StatelessWidget {
   final List<ViewPB> views;
   final PopoverMutex mutex;
 
+  @override
+  State<NotificationDialog> createState() => _NotificationDialogState();
+}
+
+class _NotificationDialogState extends State<NotificationDialog>
+    with SingleTickerProviderStateMixin {
+  late final TabController _controller = TabController(length: 2, vsync: this);
+  final PopoverMutex _mutex = PopoverMutex();
+  final ReminderBloc _reminderBloc = getIt<ReminderBloc>();
+
+  @override
+  void initState() {
+    super.initState();
+    _controller.addListener(_updateState);
+  }
+
+  void _updateState() => setState(() {});
+
+  @override
+  void dispose() {
+    _mutex.close();
+    _controller.removeListener(_updateState);
+    _controller.dispose();
+    super.dispose();
+  }
+
   @override
   Widget build(BuildContext context) {
-    final reminderBloc = getIt<ReminderBloc>();
+    return MultiBlocProvider(
+      providers: [
+        BlocProvider<ReminderBloc>.value(value: _reminderBloc),
+        BlocProvider<NotificationFilterBloc>(
+          create: (_) => NotificationFilterBloc(),
+        ),
+      ],
+      child: BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
+        builder: (context, filterState) =>
+            BlocBuilder<ReminderBloc, ReminderState>(
+          builder: (context, state) {
+            final sortDescending =
+                filterState.sortBy == NotificationSortOption.descending;
 
-    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));
+            final List<ReminderPB> pastReminders = state.pastReminders
+                .where((r) => filterState.showUnreadsOnly ? !r.isRead : true)
+                .sortByScheduledAt(isDescending: sortDescending);
+
+            final List<ReminderPB> upcomingReminders = state.upcomingReminders
+                .sortByScheduledAt(isDescending: sortDescending);
 
-          return SingleChildScrollView(
-            child: Column(
+            return Column(
+              mainAxisSize: MainAxisSize.min,
               crossAxisAlignment: CrossAxisAlignment.start,
               children: [
                 Row(
                   children: [
-                    Expanded(
-                      child: DecoratedBox(
-                        decoration: BoxDecoration(
-                          border: Border(
-                            bottom: BorderSide(
-                              color: Theme.of(context).dividerColor,
-                            ),
+                    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,
+                      ),
+                      child: SizedBox(
+                        width: 215,
+                        child: TabBar(
+                          controller: _controller,
+                          indicator: UnderlineTabIndicator(
+                            borderRadius: BorderRadius.circular(4),
+                            borderSide: BorderSide(
+                              width: 1,
+                              color: Theme.of(context).colorScheme.primary,
+                            ),
                           ),
+                          tabs: [
+                            Tab(
+                              height: 26,
+                              child: FlowyText.regular(
+                                LocaleKeys.notificationHub_tabs_inbox.tr(),
+                              ),
+                            ),
+                            Tab(
+                              height: 26,
+                              child: FlowyText.regular(
+                                LocaleKeys.notificationHub_tabs_upcoming.tr(),
+                              ),
+                            ),
+                          ],
                         ),
                       ),
                     ),
+                    const Spacer(),
+                    NotificationViewFilters(),
                   ],
                 ),
                 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),
+                // TODO(Xazin): Resolve issue with taking up
+                //  max amount of vertical space
+                Expanded(
+                  child: TabBarView(
+                    controller: _controller,
+                    children: [
+                      if (!filterState.groupByDate) ...[
+                        NotificationsView(
+                          shownReminders: pastReminders,
+                          reminderBloc: _reminderBloc,
+                          views: widget.views,
+                          onDelete: _onDelete,
+                          onAction: _onAction,
+                          onReadChanged: _onReadChanged,
                         ),
-                      ),
-                      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();
-                      },
-                    );
-                  }),
+                        NotificationsView(
+                          shownReminders: upcomingReminders,
+                          reminderBloc: _reminderBloc,
+                          views: widget.views,
+                          isUpcoming: true,
+                          onAction: _onAction,
+                        ),
+                      ] else ...[
+                        NotificationsGroupView(
+                          groupedReminders: groupBy<ReminderPB, DateTime>(
+                            pastReminders,
+                            (r) => DateTime.fromMillisecondsSinceEpoch(
+                              r.scheduledAt.toInt() * 1000,
+                            ).withoutTime,
+                          ),
+                          reminderBloc: _reminderBloc,
+                          views: widget.views,
+                          onAction: _onAction,
+                          onDelete: _onDelete,
+                          onReadChanged: _onReadChanged,
+                        ),
+                        NotificationsGroupView(
+                          groupedReminders: groupBy<ReminderPB, DateTime>(
+                            upcomingReminders,
+                            (r) => DateTime.fromMillisecondsSinceEpoch(
+                              r.scheduledAt.toInt() * 1000,
+                            ).withoutTime,
+                          ),
+                          reminderBloc: _reminderBloc,
+                          views: widget.views,
+                          isUpcoming: true,
+                          onAction: _onAction,
+                        ),
+                      ],
+                    ],
+                  ),
+                ),
               ],
+            );
+          },
+        ),
+      ),
+    );
+  }
+
+  void _onAction(ReminderPB reminder) {
+    final view = widget.views.firstWhereOrNull(
+      (view) => view.id == reminder.objectId,
+    );
+
+    if (view == null) {
+      return;
+    }
+
+    _reminderBloc.add(
+      ReminderEvent.pressReminder(reminderId: reminder.id),
+    );
+
+    widget.mutex.close();
+  }
+
+  void _onDelete(ReminderPB reminder) {
+    _reminderBloc.add(ReminderEvent.remove(reminder: reminder));
+  }
+
+  void _onReadChanged(ReminderPB reminder, bool isRead) {
+    _reminderBloc.add(
+      ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)),
+    );
+  }
+}
+
+class NotificationViewFilters extends StatelessWidget {
+  NotificationViewFilters({super.key});
+  final PopoverMutex _mutex = PopoverMutex();
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider<NotificationFilterBloc>.value(
+      value: context.read<NotificationFilterBloc>(),
+      child: BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
+        builder: (context, state) {
+          return AppFlowyPopover(
+            mutex: _mutex,
+            offset: const Offset(0, 5),
+            constraints: BoxConstraints.loose(const Size(225, 200)),
+            direction: PopoverDirection.bottomWithLeftAligned,
+            popupBuilder: (popoverContext) {
+              // TODO(Xazin): This is a workaround until we have resolved
+              //  the issues with closing popovers on leave/outside-clicks
+              return MouseRegion(
+                onExit: (_) => _mutex.close(),
+                child: NotificationFilterPopover(
+                  bloc: context.read<NotificationFilterBloc>(),
+                ),
+              );
+            },
+            child: FlowyIconButton(
+              isSelected: state.hasFilters,
+              iconColorOnHover: Theme.of(context).colorScheme.onSurface,
+              icon: const FlowySvg(FlowySvgs.filter_s),
             ),
           );
         },
@@ -121,3 +258,187 @@ class NotificationDialog extends StatelessWidget {
     );
   }
 }
+
+class NotificationFilterPopover extends StatelessWidget {
+  const NotificationFilterPopover({
+    super.key,
+    required this.bloc,
+  });
+
+  final NotificationFilterBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        _SortByOption(bloc: bloc),
+        _ShowUnreadsToggle(bloc: bloc),
+        _GroupByDateToggle(bloc: bloc),
+        BlocProvider<NotificationFilterBloc>.value(
+          value: bloc,
+          child: BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
+            builder: (context, state) {
+              return Row(
+                mainAxisAlignment: MainAxisAlignment.end,
+                children: [
+                  SizedBox(
+                    width: 115,
+                    child: FlowyButton(
+                      disable: !state.hasFilters,
+                      onTap: state.hasFilters
+                          ? () =>
+                              bloc.add(const NotificationFilterEvent.reset())
+                          : null,
+                      text: FlowyText(
+                        LocaleKeys.notificationHub_filters_resetToDefault.tr(),
+                      ),
+                    ),
+                  ),
+                ],
+              );
+            },
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class _ShowUnreadsToggle extends StatelessWidget {
+  const _ShowUnreadsToggle({required this.bloc});
+
+  final NotificationFilterBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider<NotificationFilterBloc>.value(
+      value: bloc,
+      child: BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
+        builder: (context, state) {
+          return Row(
+            children: [
+              const HSpace(4),
+              Expanded(
+                child: FlowyText(
+                  LocaleKeys.notificationHub_filters_showUnreadsOnly.tr(),
+                ),
+              ),
+              Toggle(
+                style: ToggleStyle.big,
+                onChanged: (value) => bloc
+                    .add(const NotificationFilterEvent.toggleShowUnreadsOnly()),
+                value: state.showUnreadsOnly,
+              ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}
+
+class _GroupByDateToggle extends StatelessWidget {
+  const _GroupByDateToggle({required this.bloc});
+
+  final NotificationFilterBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider<NotificationFilterBloc>.value(
+      value: bloc,
+      child: BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
+        builder: (context, state) {
+          return Row(
+            children: [
+              const HSpace(4),
+              Expanded(
+                child: FlowyText(
+                  LocaleKeys.notificationHub_filters_groupByDate.tr(),
+                ),
+              ),
+              Toggle(
+                style: ToggleStyle.big,
+                onChanged: (value) =>
+                    bloc.add(const NotificationFilterEvent.toggleGroupByDate()),
+                value: state.groupByDate,
+              ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}
+
+class _SortByOption extends StatefulWidget {
+  const _SortByOption({required this.bloc});
+
+  final NotificationFilterBloc bloc;
+
+  @override
+  State<_SortByOption> createState() => _SortByOptionState();
+}
+
+class _SortByOptionState extends State<_SortByOption> {
+  bool _isHovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider<NotificationFilterBloc>.value(
+      value: widget.bloc,
+      child: BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
+        builder: (context, state) {
+          final isSortDescending =
+              state.sortBy == NotificationSortOption.descending;
+
+          return Row(
+            children: [
+              const Expanded(
+                child: Padding(
+                  padding: EdgeInsets.only(left: 4.0),
+                  child: FlowyText('Sort'),
+                ),
+              ),
+              const Spacer(),
+              SizedBox(
+                width: 115,
+                child: FlowyHover(
+                  resetHoverOnRebuild: false,
+                  child: FlowyButton(
+                    onHover: (isHovering) => isHovering != _isHovering
+                        ? setState(() => _isHovering = isHovering)
+                        : null,
+                    onTap: () => widget.bloc.add(
+                      NotificationFilterEvent.changeSortBy(
+                        isSortDescending
+                            ? NotificationSortOption.ascending
+                            : NotificationSortOption.descending,
+                      ),
+                    ),
+                    leftIcon: FlowySvg(
+                      isSortDescending
+                          ? FlowySvgs.sort_descending_s
+                          : FlowySvgs.sort_ascending_s,
+                      color: _isHovering
+                          ? Theme.of(context).colorScheme.onSurface
+                          : Theme.of(context).iconTheme.color,
+                    ),
+                    text: FlowyText.regular(
+                      isSortDescending
+                          ? LocaleKeys.notificationHub_filters_descending.tr()
+                          : LocaleKeys.notificationHub_filters_ascending.tr(),
+                      color: _isHovering
+                          ? Theme.of(context).colorScheme.onSurface
+                          : Theme.of(context).textTheme.bodyMedium?.color,
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}

+ 58 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_group.dart

@@ -0,0 +1,58 @@
+import 'package:appflowy/workspace/presentation/notifications/notification_item.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:flutter/material.dart';
+
+class NotificationGroup extends StatelessWidget {
+  const NotificationGroup({
+    super.key,
+    required this.reminders,
+    required this.formattedDate,
+    required this.isUpcoming,
+    required this.onReadChanged,
+    required this.onDelete,
+    required this.onAction,
+  });
+
+  final List<ReminderPB> reminders;
+  final String formattedDate;
+  final bool isUpcoming;
+  final Function(ReminderPB reminder, bool isRead)? onReadChanged;
+  final Function(ReminderPB reminder)? onDelete;
+  final Function(ReminderPB reminder)? onAction;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 8),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Padding(
+            padding: const EdgeInsets.only(left: 8),
+            child: FlowyText(formattedDate),
+          ),
+          const VSpace(4),
+          ...reminders
+              .map(
+                (reminder) => NotificationItem(
+                  reminderId: reminder.id,
+                  key: ValueKey(reminder.id),
+                  title: reminder.title,
+                  scheduled: reminder.scheduledAt,
+                  body: reminder.message,
+                  isRead: reminder.isRead,
+                  readOnly: isUpcoming,
+                  onReadChanged: (isRead) =>
+                      onReadChanged?.call(reminder, isRead),
+                  onDelete: () => onDelete?.call(reminder),
+                  onAction: () => onAction?.call(reminder),
+                ),
+              )
+              .toList(),
+        ],
+      ),
+    );
+  }
+}

+ 65 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_grouped_view.dart

@@ -0,0 +1,65 @@
+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/notifications/notification_group.dart';
+import 'package:appflowy/workspace/presentation/notifications/notifications_hub_empty.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+class NotificationsGroupView extends StatelessWidget {
+  const NotificationsGroupView({
+    super.key,
+    required this.groupedReminders,
+    required this.reminderBloc,
+    required this.views,
+    this.isUpcoming = false,
+    this.onAction,
+    this.onDelete,
+    this.onReadChanged,
+  });
+
+  final Map<DateTime, List<ReminderPB>> groupedReminders;
+  final ReminderBloc reminderBloc;
+  final List<ViewPB> views;
+  final bool isUpcoming;
+  final Function(ReminderPB reminder)? onAction;
+  final Function(ReminderPB reminder)? onDelete;
+  final Function(ReminderPB reminder, bool isRead)? onReadChanged;
+
+  @override
+  Widget build(BuildContext context) {
+    if (groupedReminders.isEmpty) {
+      return const Center(child: NotificationsHubEmpty());
+    }
+
+    final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
+
+    return SingleChildScrollView(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          ...groupedReminders.values.mapIndexed(
+            (index, reminders) {
+              final formattedDate = dateFormat.formatDate(
+                groupedReminders.keys.elementAt(index),
+                false,
+              );
+
+              return NotificationGroup(
+                reminders: reminders,
+                formattedDate: formattedDate,
+                isUpcoming: isUpcoming,
+                onReadChanged: onReadChanged,
+                onDelete: onDelete,
+                onAction: onAction,
+              );
+            },
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 18 - 10
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart

@@ -1,13 +1,14 @@
 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/workspace/application/settings/date_time/date_format_ext.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');
+import 'package:provider/provider.dart';
 
 class NotificationItem extends StatefulWidget {
   const NotificationItem({
@@ -17,6 +18,7 @@ class NotificationItem extends StatefulWidget {
     required this.scheduled,
     required this.body,
     required this.isRead,
+    this.readOnly = false,
     this.onAction,
     this.onDelete,
     this.onReadChanged,
@@ -27,6 +29,7 @@ class NotificationItem extends StatefulWidget {
   final Int64 scheduled;
   final String body;
   final bool isRead;
+  final bool readOnly;
 
   final VoidCallback? onAction;
   final VoidCallback? onDelete;
@@ -53,7 +56,7 @@ class _NotificationItemState extends State<NotificationItem> {
           GestureDetector(
             onTap: widget.onAction,
             child: Opacity(
-              opacity: widget.isRead ? 0.5 : 1,
+              opacity: widget.isRead && !widget.readOnly ? 0.5 : 1,
               child: Container(
                 padding: const EdgeInsets.all(10),
                 decoration: BoxDecoration(
@@ -68,7 +71,7 @@ class _NotificationItemState extends State<NotificationItem> {
                     Stack(
                       children: [
                         const FlowySvg(FlowySvgs.time_s, size: Size.square(20)),
-                        if (!widget.isRead)
+                        if (!widget.isRead && !widget.readOnly)
                           Positioned(
                             bottom: 1,
                             right: 1,
@@ -89,11 +92,12 @@ class _NotificationItemState extends State<NotificationItem> {
                         mainAxisAlignment: MainAxisAlignment.center,
                         children: [
                           Row(
-                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
                             children: [
-                              Flexible(
-                                child: FlowyText.semibold(widget.title),
+                              FlowyText.semibold(
+                                widget.title,
+                                fontSize: 14,
                               ),
+                              const HSpace(8),
                               FlowyText.regular(
                                 _scheduledString(widget.scheduled),
                                 fontSize: 10,
@@ -110,7 +114,7 @@ class _NotificationItemState extends State<NotificationItem> {
               ),
             ),
           ),
-          if (_isHovering)
+          if (_isHovering && !widget.readOnly)
             Positioned(
               right: 4,
               top: 4,
@@ -125,9 +129,13 @@ class _NotificationItemState extends State<NotificationItem> {
     );
   }
 
-  String _scheduledString(Int64 secondsSinceEpoch) =>
-      _dateFormat(context).format(
+  String _scheduledString(Int64 secondsSinceEpoch) => context
+      .read<AppearanceSettingsCubit>()
+      .state
+      .dateFormat
+      .formatDate(
         DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch.toInt() * 1000),
+        true,
       );
 
   void _onHover(bool isHovering) => setState(() => _isHovering = isHovering);

+ 59 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_view.dart

@@ -0,0 +1,59 @@
+import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
+import 'package:appflowy/workspace/presentation/notifications/notification_item.dart';
+import 'package:appflowy/workspace/presentation/notifications/notifications_hub_empty.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
+import 'package:flutter/material.dart';
+
+class NotificationsView extends StatelessWidget {
+  const NotificationsView({
+    super.key,
+    required this.shownReminders,
+    required this.reminderBloc,
+    required this.views,
+    this.isUpcoming = false,
+    this.onAction,
+    this.onDelete,
+    this.onReadChanged,
+  });
+
+  final List<ReminderPB> shownReminders;
+  final ReminderBloc reminderBloc;
+  final List<ViewPB> views;
+  final bool isUpcoming;
+  final Function(ReminderPB reminder)? onAction;
+  final Function(ReminderPB reminder)? onDelete;
+  final Function(ReminderPB reminder, bool isRead)? onReadChanged;
+
+  @override
+  Widget build(BuildContext context) {
+    if (shownReminders.isEmpty) {
+      return const Center(child: NotificationsHubEmpty());
+    }
+
+    return SingleChildScrollView(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          ...shownReminders.map(
+            (reminder) {
+              return NotificationItem(
+                reminderId: reminder.id,
+                key: ValueKey(reminder.id),
+                title: reminder.title,
+                scheduled: reminder.scheduledAt,
+                body: reminder.message,
+                isRead: reminder.isRead,
+                readOnly: isUpcoming,
+                onReadChanged: (isRead) =>
+                    onReadChanged?.call(reminder, isRead),
+                onDelete: () => onDelete?.call(reminder),
+                onAction: () => onAction?.call(reminder),
+              );
+            },
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 20 - 0
frontend/appflowy_flutter/lib/workspace/presentation/notifications/notifications_hub_empty.dart

@@ -0,0 +1,20 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+
+class NotificationsHubEmpty extends StatelessWidget {
+  const NotificationsHubEmpty({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(vertical: 12),
+      child: Center(
+        child: FlowyText.regular(
+          LocaleKeys.notificationHub_empty.tr(),
+        ),
+      ),
+    );
+  }
+}

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart

@@ -164,7 +164,7 @@ class _ChangeStoragePathButton extends StatefulWidget {
 class _ChangeStoragePathButtonState extends State<_ChangeStoragePathButton> {
   @override
   Widget build(BuildContext context) {
-    return FlowyTooltip.delayed(
+    return FlowyTooltip(
       message: LocaleKeys.settings_files_changeLocationTooltips.tr(),
       child: SecondaryTextButton(
         LocaleKeys.settings_files_change.tr(),

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart

@@ -239,7 +239,7 @@ class SettingsUserView extends StatelessWidget {
     required bool hasIcon,
     required Widget child,
   }) =>
-      FlowyTooltip.delayed(
+      FlowyTooltip(
         message: LocaleKeys.settings_user_tooltipSelectIcon.tr(),
         child: Stack(
           children: [

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart

@@ -106,7 +106,7 @@ class EnableEncrypt extends StatelessWidget {
                 const VSpace(6),
                 SizedBox(
                   height: 40,
-                  child: FlowyTooltip.delayed(
+                  child: FlowyTooltip(
                     message: LocaleKeys.settings_menu_clickToCopySecret.tr(),
                     child: FlowyButton(
                       disable: !(state.config.enableEncrypt),

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

@@ -195,7 +195,7 @@ class FlowyTextButton extends StatelessWidget {
     );
 
     if (tooltip != null) {
-      child = FlowyTooltip.delayed(
+      child = FlowyTooltip(
         message: tooltip!,
         child: child,
       );
@@ -284,7 +284,7 @@ class FlowyRichTextButton extends StatelessWidget {
     );
 
     if (tooltip != null) {
-      child = FlowyTooltip.delayed(
+      child = FlowyTooltip(
         message: tooltip!,
         child: child,
       );

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

@@ -83,7 +83,7 @@ class _FlowyHoverState extends State<FlowyHover> {
   }
 
   Widget renderWidget() {
-    var showHover = _onHover;
+    bool showHover = _onHover;
     if (!showHover && widget.isSelected != null) {
       showHover = widget.isSelected!();
     }

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

@@ -58,7 +58,7 @@ class FlowyIconButton extends StatelessWidget {
         height: size.height,
       ),
       decoration: decoration,
-      child: FlowyTooltip.delayed(
+      child: FlowyTooltip(
         preferBelow: preferBelow,
         message: tooltipMessage,
         richMessage: richTooltipText,

+ 20 - 9
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart

@@ -2,15 +2,26 @@ import 'package:flutter/material.dart';
 
 const _tooltipWaitDuration = Duration(milliseconds: 300);
 
-class FlowyTooltip {
-  static Tooltip delayed({
-    String? message,
-    InlineSpan? richMessage,
-    bool? preferBelow,
-    Duration? showDuration,
-    Widget? child,
-    EdgeInsetsGeometry? margin,
-  }) {
+class FlowyTooltip extends StatelessWidget {
+  const FlowyTooltip({
+    super.key,
+    this.message,
+    this.richMessage,
+    this.preferBelow,
+    this.showDuration,
+    this.margin,
+    this.child,
+  });
+
+  final String? message;
+  final InlineSpan? richMessage;
+  final bool? preferBelow;
+  final Duration? showDuration;
+  final EdgeInsetsGeometry? margin;
+  final Widget? child;
+
+  @override
+  Widget build(BuildContext context) {
     return Tooltip(
       margin: margin,
       waitDuration: _tooltipWaitDuration,

+ 12 - 1
frontend/resources/translations/en.json

@@ -814,7 +814,18 @@
   },
   "notificationHub": {
     "title": "Notifications",
-    "empty": "Nothing to see here!"
+    "empty": "Nothing to see here!",
+    "tabs": {
+      "inbox": "Inbox",
+      "upcoming": "Upcoming"
+    },
+    "filters": {
+      "ascending": "Ascending",
+      "descending": "Descending",
+      "groupByDate": "Group by date",
+      "showUnreadsOnly": "Show unreads only",
+      "resetToDefault": "Reset to default"
+    }
   },
   "reminderNotification": {
     "title": "Reminder",