diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart index 128dc85416..ef46713b12 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart @@ -67,7 +67,7 @@ class _PrimaryCellAccessoryState extends State with GridCellAccessoryState { @override Widget build(BuildContext context) { - return FlowyTooltip.delayed( + return FlowyTooltip( message: LocaleKeys.tooltip_openAsPage.tr(), child: SizedBox( width: 26, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart index a4c8c5cc1c..d2aae24512 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart +++ b/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( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart index a9a417b514..bd88f2fa8a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart +++ b/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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart index 5cbaf4719e..d670f69801 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart @@ -225,11 +225,9 @@ class _LinkToPageMenuState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); } + + return const Center(child: CircularProgressIndicator()); }, future: items, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart index fbaadd413d..5266d3f357 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart +++ b/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, diff --git a/frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart b/frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart new file mode 100644 index 0000000000..fb668c8cd7 --- /dev/null +++ b/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 { + NotificationFilterBloc() : super(const NotificationFilterState()) { + on((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 get props => [showUnreadsOnly, groupByDate, sortBy]; +} diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index 7469b8f967..400c6e7db5 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -34,19 +34,19 @@ class ReminderBloc extends Bloc { 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 { 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 { 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 { }); } - void _updateState(Emitter emit, List 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? reminders, - bool? hasUnreads, - }) : reminders = reminders ?? [], - hasUnreads = hasUnreads ?? false; + ReminderState({List? reminders}) { + _reminders = reminders ?? []; - final List reminders; - final bool hasUnreads; + pastReminders = []; + upcomingReminders = []; - ReminderState copyWith({ - List? reminders, - bool? hasUnreads, - }) => - ReminderState( - reminders: reminders ?? this.reminders, - hasUnreads: hasUnreads ?? this.hasUnreads, + 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 _reminders; + List get reminders => _reminders; + + late final List pastReminders; + late final List upcomingReminders; + late final bool hasUnreads; + + ReminderState copyWith({List? reminders}) => + ReminderState(reminders: reminders ?? _reminders); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart index 933fb7a943..ef044b06d7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart +++ b/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, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart index 2be8200f65..32479e1ffe 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart +++ b/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: () { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index 814060ec71..efdef8197e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -339,7 +339,7 @@ class _SingleInnerViewItemState extends State { // + 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 { // ยทยทยท more action button Widget _buildViewMoreActionButton(BuildContext context) { - return FlowyTooltip.delayed( + return FlowyTooltip( message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), child: ViewMoreActionButton( view: widget.view, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart index 7595d2cca3..3aacd41856 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart +++ b/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(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart index 21da60e163..8c644ba9e1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart @@ -24,14 +24,14 @@ class NotificationButton extends StatelessWidget { return BlocProvider.value( value: getIt(), child: BlocBuilder( - 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), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart index f61df0111c..da546ff8a7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart +++ b/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 { + List 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 views; final PopoverMutex mutex; + @override + State createState() => _NotificationDialogState(); +} + +class _NotificationDialogState extends State + with SingleTickerProviderStateMixin { + late final TabController _controller = TabController(length: 2, vsync: this); + final PopoverMutex _mutex = PopoverMutex(); + final ReminderBloc _reminderBloc = getIt(); + + @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(); + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: _reminderBloc), + BlocProvider( + create: (_) => NotificationFilterBloc(), + ), + ], + child: BlocBuilder( + builder: (context, filterState) => + BlocBuilder( + builder: (context, state) { + final sortDescending = + filterState.sortBy == NotificationSortOption.descending; - return BlocProvider.value( - value: reminderBloc, - child: BlocBuilder( - builder: (context, state) { - final shownReminders = state.reminders - .where((reminder) => reminder.isBefore(DateTime.now())) - .sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt)); + final List pastReminders = state.pastReminders + .where((r) => filterState.showUnreadsOnly ? !r.isRead : true) + .sortByScheduledAt(isDescending: sortDescending); - return SingleChildScrollView( - child: Column( + final List upcomingReminders = state.upcomingReminders + .sortByScheduledAt(isDescending: sortDescending); + + 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, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 10, - ), - child: FlowyText.semibold( - LocaleKeys.notificationHub_title.tr(), - fontSize: 16, + DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, ), ), ), + 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( + pastReminders, + (r) => DateTime.fromMillisecondsSinceEpoch( + r.scheduledAt.toInt() * 1000, + ).withoutTime, + ), + reminderBloc: _reminderBloc, + views: widget.views, + onAction: _onAction, + onDelete: _onDelete, + onReadChanged: _onReadChanged, + ), + NotificationsGroupView( + groupedReminders: groupBy( + 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.value( + value: context.read(), + child: BlocBuilder( + 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(), + ), + ); + }, + 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.value( + value: bloc, + child: BlocBuilder( + 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.value( + value: bloc, + child: BlocBuilder( + 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.value( + value: bloc, + child: BlocBuilder( + 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.value( + value: widget.bloc, + child: BlocBuilder( + 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, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_group.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_group.dart new file mode 100644 index 0000000000..4cd9ade0d6 --- /dev/null +++ b/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 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(), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_grouped_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_grouped_view.dart new file mode 100644 index 0000000000..f587cd7e67 --- /dev/null +++ b/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> groupedReminders; + final ReminderBloc reminderBloc; + final List 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().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, + ); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart index 6dee6a819b..d35698e045 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart +++ b/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 { 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 { 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 { 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 { ), ), ), - if (_isHovering) + if (_isHovering && !widget.readOnly) Positioned( right: 4, top: 4, @@ -125,9 +129,13 @@ class _NotificationItemState extends State { ); } - String _scheduledString(Int64 secondsSinceEpoch) => - _dateFormat(context).format( + String _scheduledString(Int64 secondsSinceEpoch) => context + .read() + .state + .dateFormat + .formatDate( DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch.toInt() * 1000), + true, ); void _onHover(bool isHovering) => setState(() => _isHovering = isHovering); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_view.dart new file mode 100644 index 0000000000..02212d7245 --- /dev/null +++ b/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 shownReminders; + final ReminderBloc reminderBloc; + final List 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), + ); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notifications_hub_empty.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notifications_hub_empty.dart new file mode 100644 index 0000000000..a0971051f9 --- /dev/null +++ b/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(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart index 2e3f7c3121..976f813a51 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart +++ b/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(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart index ffc004cd36..00f15c9ab9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/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: [ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart index 39ad66a4d5..18bac66a11 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/sync_setting_view.dart +++ b/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), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index daef47f052..136ff4a743 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/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, ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart index 70b64f3230..585ae7b5a8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart @@ -83,7 +83,7 @@ class _FlowyHoverState extends State { } Widget renderWidget() { - var showHover = _onHover; + bool showHover = _onHover; if (!showHover && widget.isSelected != null) { showHover = widget.isSelected!(); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index 9cc9dee372..2f8ce2aaf5 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/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, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart index 19d67c3c3e..47a684cf01 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart +++ b/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, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b9b808fc5a..ed8907947b 100644 --- a/frontend/resources/translations/en.json +++ b/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",