feat: reminder improvements (#3658)

This commit is contained in:
Mathias Mogensen 2023-10-12 04:19:36 +02:00 committed by GitHub
parent 058eeec932
commit bc8f35d7db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 785 additions and 154 deletions

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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,
);

View File

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

View File

@ -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];
}

View File

@ -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;
ReminderState({List<ReminderPB>? reminders}) {
_reminders = reminders ?? [];
final List<ReminderPB> reminders;
final bool hasUnreads;
pastReminders = [];
upcomingReminders = [];
ReminderState copyWith({
List<ReminderPB>? 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<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);
}

View File

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

View File

@ -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: () {

View File

@ -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,

View File

@ -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(),

View File

@ -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),

View File

@ -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);
return SingleChildScrollView(
child: Column(
final List<ReminderPB> 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<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,
),
),
),
),
],
);
},
),
);
}
}

View File

@ -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(),
],
),
);
}
}

View File

@ -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,
);
},
),
],
),
);
}
}

View File

@ -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);

View File

@ -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),
);
},
),
],
),
);
}
}

View File

@ -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(),
),
),
);
}
}

View File

@ -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(),

View File

@ -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: [

View File

@ -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),

View File

@ -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,
);

View File

@ -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!();
}

View File

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

View File

@ -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,

View File

@ -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",