From 649545cdf30ecb61bf8f36f9c5821b478aac2fd5 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Fri, 8 Dec 2023 15:04:09 +0200 Subject: [PATCH] feat: mobile notifications screen (#4100) * fix: update username on mobile header on change * feat: notifications page * feat: refactor and refinement * fix: code review --- .../user_profile/user_profile_bloc.dart | 65 +++++ .../home/mobile_home_page_header.dart | 26 +- .../mobile_notifications_page.dart | 167 +++++++++++ .../widgets/mobile_notification_tab_bar.dart | 69 +++++ .../personal_info_setting_group.dart | 10 +- .../lib/plugins/document/document_page.dart | 62 ++--- .../error/error_block_component_builder.dart | 4 +- .../lib/startup/tasks/app_widget.dart | 52 +++- .../lib/startup/tasks/generate_router.dart | 26 +- .../application/reminder/reminder_bloc.dart | 5 +- .../notifications/notification_action.dart | 1 + .../application/user/settings_user_bloc.dart | 2 +- .../notifications/notification_dialog.dart | 181 +----------- .../notifications/reminder_extension.dart | 7 + .../notifications/widgets/flowy_tab.dart | 37 +++ .../widgets/inbox_action_bar.dart | 174 ++++++++++++ .../widgets/notification_item.dart | 260 ++++++++++-------- .../widgets/notification_tab_bar.dart | 30 +- .../widgets/notification_view.dart | 117 ++++---- .../widgets/notifications_hub_empty.dart | 1 + frontend/resources/translations/en.json | 5 +- 21 files changed, 828 insertions(+), 473 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart diff --git a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart new file mode 100644 index 0000000000..2d257e38b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_profile_bloc.freezed.dart'; + +class UserProfileBloc extends Bloc { + UserProfileBloc() : super(const _Initial()) { + on((event, emit) async { + await event.when( + started: () async => _initalize(emit), + ); + }); + } + + Future _initalize(Emitter emit) async { + emit(const UserProfileState.loading()); + + final workspaceOrFailure = + await FolderEventGetCurrentWorkspaceSetting().send(); + + final userOrFailure = await getIt().getUser(); + + final workspaceSetting = workspaceOrFailure.fold( + (workspaceSettingPB) => workspaceSettingPB, + (error) => null, + ); + + final userProfile = userOrFailure.fold( + (error) => null, + (userProfilePB) => userProfilePB, + ); + + if (workspaceSetting == null || userProfile == null) { + return emit(const UserProfileState.workspaceFailure()); + } + + emit( + UserProfileState.success( + workspaceSettings: workspaceSetting, + userProfile: userProfile, + ), + ); + } +} + +@freezed +class UserProfileEvent with _$UserProfileEvent { + const factory UserProfileEvent.started() = _Started; +} + +@freezed +class UserProfileState with _$UserProfileState { + const factory UserProfileState.initial() = _Initial; + const factory UserProfileState.loading() = _Loading; + const factory UserProfileState.workspaceFailure() = _WorkspaceFailure; + const factory UserProfileState.success({ + required WorkspaceSettingPB workspaceSettings, + required UserProfilePB userProfile, + }) = _Success; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index a6a9e1aa06..0bbae4e4eb 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -14,16 +14,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileHomePageHeader extends StatelessWidget { - const MobileHomePageHeader({ - super.key, - required this.userProfile, - }); + const MobileHomePageHeader({super.key, required this.userProfile}); final UserProfilePB userProfile; @override Widget build(BuildContext context) { - final theme = Theme.of(context); return BlocProvider( create: (context) => getIt(param1: userProfile) ..add(const SettingsUserEvent.initial()), @@ -41,29 +37,23 @@ class MobileHomePageHeader extends StatelessWidget { mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const FlowyText.medium( - 'AppFlowy', - fontSize: 18, - ), + const FlowyText.medium('AppFlowy', fontSize: 18), const VSpace(4), FlowyText.regular( userProfile.email.isNotEmpty - ? userProfile.email - : userProfile.name, + ? state.userProfile.email + : state.userProfile.name, fontSize: 12, - color: theme.colorScheme.onSurface, + color: Theme.of(context).colorScheme.onSurface, overflow: TextOverflow.ellipsis, ), ], ), ), IconButton( - onPressed: () { - context.push(MobileHomeSettingPage.routeName); - }, - icon: const FlowySvg( - FlowySvgs.m_setting_m, - ), + onPressed: () => + context.push(MobileHomeSettingPage.routeName), + icon: const FlowySvg(FlowySvgs.m_setting_m), ), ], ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart new file mode 100644 index 0000000000..53d1a34ddd --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -0,0 +1,167 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/user_profile/user_profile_bloc.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.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/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy/workspace/presentation/notifications/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_view.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileNotificationsScreen extends StatefulWidget { + const MobileNotificationsScreen({super.key}); + + static const routeName = '/notifications'; + + @override + State createState() => + _MobileNotificationsScreenState(); +} + +class _MobileNotificationsScreenState extends State + with SingleTickerProviderStateMixin { + final ReminderBloc _reminderBloc = getIt(); + late final TabController _controller = TabController(length: 2, vsync: this); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + UserProfileBloc()..add(const UserProfileEvent.started()), + ), + BlocProvider.value(value: _reminderBloc), + BlocProvider( + create: (_) => NotificationFilterBloc(), + ), + ], + child: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => + const Center(child: CircularProgressIndicator.adaptive()), + workspaceFailure: () => const WorkspaceFailedScreen(), + success: (workspaceSetting, userProfile) => + _NotificationScreenContent( + workspaceSetting: workspaceSetting, + userProfile: userProfile, + controller: _controller, + reminderBloc: _reminderBloc, + ), + ); + }, + ), + ); + } +} + +class _NotificationScreenContent extends StatelessWidget { + const _NotificationScreenContent({ + required this.workspaceSetting, + required this.userProfile, + required this.controller, + required this.reminderBloc, + }); + + final WorkspaceSettingPB workspaceSetting; + final UserProfilePB userProfile; + final TabController controller; + final ReminderBloc reminderBloc; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MenuBloc( + workspaceId: workspaceSetting.workspaceId, + user: userProfile, + )..add(const MenuEvent.initial()), + child: BlocBuilder( + builder: (context, menuState) => + BlocBuilder( + builder: (context, filterState) => + BlocBuilder( + builder: (context, state) { + // Workaround for rebuilding the Blocks by brightness + Theme.of(context).brightness; + + final List pastReminders = state.pastReminders + .where( + (r) => filterState.showUnreadsOnly ? !r.isRead : true, + ) + .sortByScheduledAt(); + + final List upcomingReminders = + state.upcomingReminders.sortByScheduledAt(); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + elevation: 0, + title: Text(LocaleKeys.notificationHub_mobile_title.tr()), + ), + body: SafeArea( + child: Column( + children: [ + MobileNotificationTabBar(controller: controller), + Expanded( + child: TabBarView( + controller: controller, + children: [ + NotificationsView( + shownReminders: pastReminders, + reminderBloc: reminderBloc, + views: menuState.views, + onAction: _onAction, + onDelete: _onDelete, + onReadChanged: _onReadChanged, + actionBar: InboxActionBar( + hasUnreads: state.hasUnreads, + showUnreadsOnly: filterState.showUnreadsOnly, + ), + ), + NotificationsView( + shownReminders: upcomingReminders, + reminderBloc: reminderBloc, + views: menuState.views, + isUpcoming: true, + onAction: _onAction, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } + + void _onAction(ReminderPB reminder, int? path, ViewPB? view) => + reminderBloc.add( + ReminderEvent.pressReminder( + reminderId: reminder.id, + path: path, + view: view, + ), + ); + + 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)), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart new file mode 100644 index 0000000000..471b456bc0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class MobileNotificationTabBar extends StatefulWidget { + const MobileNotificationTabBar({super.key, required this.controller}); + + final TabController controller; + + @override + State createState() => + _MobileNotificationTabBarState(); +} + +class _MobileNotificationTabBarState extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(_updateState); + } + + void _updateState() => setState(() {}); + + @override + Widget build(BuildContext context) { + final borderSide = BorderSide( + color: AFThemeExtension.of(context).calloutBGColor, + ); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: borderSide, + top: borderSide, + ), + ), + child: Row( + children: [ + Expanded( + child: TabBar( + controller: widget.controller, + padding: const EdgeInsets.symmetric(horizontal: 8), + labelPadding: EdgeInsets.zero, + indicatorSize: TabBarIndicatorSize.label, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + isScrollable: true, + tabs: [ + FlowyTabItem( + label: LocaleKeys.notificationHub_tabs_inbox.tr(), + isSelected: widget.controller.index == 0, + ), + FlowyTabItem( + label: LocaleKeys.notificationHub_tabs_upcoming.tr(), + isSelected: widget.controller.index == 1, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index 5419c39cf7..39f54c6d1b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -50,13 +50,9 @@ class PersonalInfoSettingGroup extends StatelessWidget { return EditUsernameBottomSheet( context, userName: userName, - onSubmitted: (value) { - context.read().add( - SettingsUserEvent.updateUserName( - value, - ), - ); - }, + onSubmitted: (value) => context + .read() + .add(SettingsUserEvent.updateUserName(value)), ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 556a5d5a71..20c905eb9d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -87,39 +87,37 @@ class _DocumentPageState extends State { child: BlocListener( listener: _onNotificationAction, child: BlocBuilder( - builder: (context, state) { - return state.loadingState.when( - loading: () => - const Center(child: CircularProgressIndicator.adaptive()), - finish: (result) => result.fold( - (error) { - Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + builder: (context, state) => state.loadingState.when( + loading: () => + const Center(child: CircularProgressIndicator.adaptive()), + finish: (result) => result.fold( + (error) { + Log.error(error); + return FlowyErrorPage.message( + error.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ); + }, + (data) { + if (state.forceClose) { + widget.onDeleted(); + return const SizedBox.shrink(); + } else if (documentBloc.editorState == null) { + return Center( + child: ExportPageWidget( + onTap: () async => await _exportPage(data), + ), ); - }, - (data) { - if (state.forceClose) { - widget.onDeleted(); - return const SizedBox.shrink(); - } else if (documentBloc.editorState == null) { - return Center( - child: ExportPageWidget( - onTap: () async => await _exportPage(data), - ), - ); - } else { - editorState = documentBloc.editorState!; - return _buildEditorPage( - context, - state, - ); - } - }, - ), - ); - }, + } else { + editorState = documentBloc.editorState!; + return _buildEditorPage( + context, + state, + ); + } + }, + ), + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart index 914a4bef57..3fdf332eb3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart @@ -46,10 +46,10 @@ class ErrorBlockComponentWidget extends BlockComponentStatefulWidget { @override State createState() => - _DividerBlockComponentWidgetState(); + _ErrorBlockComponentWidgetState(); } -class _DividerBlockComponentWidgetState extends State +class _ErrorBlockComponentWidgetState extends State with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index d6aea790d3..d486e404b3 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,6 +1,9 @@ +import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; @@ -137,18 +140,44 @@ class _ApplicationWidgetState extends State { BlocProvider( create: (_) => DocumentAppearanceCubit()..fetch(), ), + BlocProvider.value(value: getIt()), ], - child: BlocBuilder( - builder: (context, state) => MaterialApp.router( - builder: overlayManagerBuilder(), - debugShowCheckedModeBanner: false, - theme: state.lightTheme, - darkTheme: state.darkTheme, - themeMode: state.themeMode, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: state.locale, - routerConfig: routerConfig, + child: BlocListener( + listener: (context, state) { + if (state.action?.type == ActionType.openView) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final view = + state.action!.arguments?[ActionArgumentKeys.view.name]; + if (view != null) { + AppGlobals.rootNavKey.currentContext?.pushView(view); + + final nodePath = state.action! + .arguments?[ActionArgumentKeys.nodePath.name] as int?; + + if (nodePath != null) { + context.read().add( + NotificationActionEvent.performAction( + action: state.action! + .copyWith(type: ActionType.jumpToBlock), + ), + ); + } + } + }); + } + }, + child: BlocBuilder( + builder: (context, state) => MaterialApp.router( + builder: overlayManagerBuilder(), + debugShowCheckedModeBanner: false, + theme: state.lightTheme, + darkTheme: state.darkTheme, + themeMode: state.themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: state.locale, + routerConfig: routerConfig, + ), ), ), ); @@ -163,7 +192,6 @@ class AppGlobals { class ApplicationBlocObserver extends BlocObserver { @override - // ignore: unnecessary_overrides void onTransition(Bloc bloc, Transition transition) { // Log.debug("[current]: ${transition.currentState} \n\n[next]: ${transition.nextState}"); // Log.debug("${transition.nextState}"); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 96f17c72f1..a89b59df63 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -9,6 +9,7 @@ import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_scr import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_page.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_page.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; @@ -181,21 +182,8 @@ StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() { StatefulShellBranch( routes: [ GoRoute( - path: '/e', - builder: (BuildContext context, GoRouterState state) => - const RootPlaceholderScreen( - label: 'Notification', - detailsPath: '/e/details', - ), - routes: [ - GoRoute( - path: 'details', - builder: (BuildContext context, GoRouterState state) => - const DetailsPlaceholderScreen( - label: 'Notification Page details', - ), - ), - ], + path: MobileNotificationsScreen.routeName, + builder: (_, __) => const MobileNotificationsScreen(), ), ], ), @@ -492,12 +480,8 @@ GoRoute _mobileEditorScreenRoute() { pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileEditorScreen.viewId]!; final title = state.uri.queryParameters[MobileEditorScreen.viewTitle]; - return MaterialPage( - child: MobileEditorScreen( - id: id, - title: title, - ), - ); + + return MaterialPage(child: MobileEditorScreen(id: id, title: title)); }, ); } 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 4110131814..92eeebde14 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -9,6 +9,7 @@ import 'package:appflowy/workspace/application/notifications/notification_action import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_service.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; @@ -106,7 +107,7 @@ class ReminderBloc extends Bloc { }, ); }, - pressReminder: (reminderId, path) { + pressReminder: (reminderId, path, view) { final reminder = state.reminders.firstWhereOrNull((r) => r.id == reminderId); @@ -129,6 +130,7 @@ class ReminderBloc extends Bloc { objectId: reminder.objectId, arguments: { ActionArgumentKeys.nodePath.name: path, + ActionArgumentKeys.view.name: view, }, ), ), @@ -201,6 +203,7 @@ class ReminderEvent with _$ReminderEvent { const factory ReminderEvent.pressReminder({ required String reminderId, @Default(null) int? path, + @Default(null) ViewPB? view, }) = _PressReminder; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart index 04720649f9..2fc470ec3e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart @@ -33,6 +33,7 @@ class NotificationAction { } enum ActionArgumentKeys { + view('view'), nodePath('node_path'); final String name; diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart index a7c7c3fe56..989d7c642b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -154,7 +154,7 @@ class SettingsUserState with _$SettingsUserState { factory SettingsUserState.initial(UserProfilePB userProfile) => SettingsUserState( userProfile: userProfile, - historicalUsers: [], + historicalUsers: const [], successOrFailure: left(unit), ); } 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 132fefc022..41fb826825 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -1,28 +1,17 @@ -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/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_hub_title.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_view.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme_extension.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 _ReminderSort on Iterable { - List sortByScheduledAt() => - sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt)); -} - class NotificationDialog extends StatefulWidget { const NotificationDialog({ super.key, @@ -98,7 +87,7 @@ class _NotificationDialogState extends State onDelete: _onDelete, onAction: _onAction, onReadChanged: _onReadChanged, - actionBar: _InboxActionBar( + actionBar: InboxActionBar( hasUnreads: state.hasUnreads, showUnreadsOnly: filterState.showUnreadsOnly, ), @@ -121,7 +110,7 @@ class _NotificationDialogState extends State ); } - void _onAction(ReminderPB reminder, int? path) { + void _onAction(ReminderPB reminder, int? path, ViewPB? view) { _reminderBloc.add( ReminderEvent.pressReminder(reminderId: reminder.id, path: path), ); @@ -139,165 +128,3 @@ class _NotificationDialogState extends State ); } } - -class _InboxActionBar extends StatelessWidget { - const _InboxActionBar({ - required this.hasUnreads, - required this.showUnreadsOnly, - }); - - final bool hasUnreads; - final bool showUnreadsOnly; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _MarkAsReadButton( - onMarkAllRead: !hasUnreads - ? null - : () => context - .read() - .add(const ReminderEvent.markAllRead()), - ), - _ToggleUnreadsButton( - showUnreadsOnly: showUnreadsOnly, - onToggled: (showUnreadsOnly) => context - .read() - .add(const NotificationFilterEvent.toggleShowUnreadsOnly()), - ), - ], - ), - ), - ); - } -} - -class _ToggleUnreadsButton extends StatefulWidget { - const _ToggleUnreadsButton({ - required this.onToggled, - this.showUnreadsOnly = false, - }); - - final Function(bool) onToggled; - final bool showUnreadsOnly; - - @override - State<_ToggleUnreadsButton> createState() => _ToggleUnreadsButtonState(); -} - -class _ToggleUnreadsButtonState extends State<_ToggleUnreadsButton> { - late bool showUnreadsOnly = widget.showUnreadsOnly; - - @override - Widget build(BuildContext context) { - return SegmentedButton( - onSelectionChanged: (Set newSelection) { - setState(() => showUnreadsOnly = newSelection.first); - widget.onToggled(showUnreadsOnly); - }, - showSelectedIcon: false, - style: ButtonStyle( - side: MaterialStatePropertyAll( - BorderSide(color: Theme.of(context).dividerColor), - ), - shape: const MaterialStatePropertyAll( - RoundedRectangleBorder(borderRadius: Corners.s6Border), - ), - foregroundColor: MaterialStateProperty.resolveWith( - (state) { - if (state.contains(MaterialState.hovered) || - state.contains(MaterialState.selected) || - state.contains(MaterialState.pressed)) { - return Theme.of(context).colorScheme.onSurface; - } - - return AFThemeExtension.of(context).textColor; - }, - ), - backgroundColor: MaterialStateProperty.resolveWith( - (state) { - if (state.contains(MaterialState.hovered) || - state.contains(MaterialState.selected) || - state.contains(MaterialState.pressed)) { - return Theme.of(context).colorScheme.primary; - } - - return Theme.of(context).cardColor; - }, - ), - ), - segments: [ - ButtonSegment( - value: false, - label: Text( - LocaleKeys.notificationHub_actions_showAll.tr(), - style: const TextStyle(fontSize: 12), - ), - ), - ButtonSegment( - value: true, - label: Text( - LocaleKeys.notificationHub_actions_showUnreads.tr(), - style: const TextStyle(fontSize: 12), - ), - ), - ], - selected: {showUnreadsOnly}, - ); - } -} - -class _MarkAsReadButton extends StatefulWidget { - final VoidCallback? onMarkAllRead; - - const _MarkAsReadButton({this.onMarkAllRead}); - - @override - State<_MarkAsReadButton> createState() => _MarkAsReadButtonState(); -} - -class _MarkAsReadButtonState extends State<_MarkAsReadButton> { - bool _isHovering = false; - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: widget.onMarkAllRead != null ? 1 : 0.5, - child: FlowyHover( - onHover: (isHovering) => setState(() => _isHovering = isHovering), - resetHoverOnRebuild: false, - child: FlowyTextButton( - LocaleKeys.notificationHub_actions_markAllRead.tr(), - fontColor: widget.onMarkAllRead != null && _isHovering - ? Theme.of(context).colorScheme.onSurface - : AFThemeExtension.of(context).textColor, - heading: FlowySvg( - FlowySvgs.checklist_s, - color: widget.onMarkAllRead != null && _isHovering - ? Theme.of(context).colorScheme.onSurface - : AFThemeExtension.of(context).textColor, - ), - hoverColor: widget.onMarkAllRead != null && _isHovering - ? Theme.of(context).colorScheme.primary - : null, - onPressed: widget.onMarkAllRead, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart new file mode 100644 index 0000000000..3abe163090 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart @@ -0,0 +1,7 @@ +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:collection/collection.dart'; + +extension ReminderSort on Iterable { + List sortByScheduledAt() => + sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt)); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart new file mode 100644 index 0000000000..4d70a6f693 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/util/platform_extension.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class FlowyTabItem extends StatelessWidget { + static const double mobileHeight = 40; + static const EdgeInsets mobilePadding = EdgeInsets.symmetric(horizontal: 12); + + static const double desktopHeight = 26; + static const EdgeInsets desktopPadding = EdgeInsets.symmetric(horizontal: 8); + + const FlowyTabItem({ + super.key, + required this.label, + required this.isSelected, + }); + + final String label; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Tab( + height: PlatformExtension.isMobile ? mobileHeight : desktopHeight, + child: Padding( + padding: PlatformExtension.isMobile ? mobilePadding : desktopPadding, + child: FlowyText.regular( + label, + color: isSelected + ? AFThemeExtension.of(context).textColor + : Theme.of(context).hintColor, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart new file mode 100644 index 0000000000..ca924aac20 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart @@ -0,0 +1,174 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class InboxActionBar extends StatelessWidget { + const InboxActionBar({ + super.key, + required this.hasUnreads, + required this.showUnreadsOnly, + }); + + final bool hasUnreads; + final bool showUnreadsOnly; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: AFThemeExtension.of(context).calloutBGColor, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _MarkAsReadButton( + onMarkAllRead: !hasUnreads + ? null + : () => context + .read() + .add(const ReminderEvent.markAllRead()), + ), + _ToggleUnreadsButton( + showUnreadsOnly: showUnreadsOnly, + onToggled: (_) => context + .read() + .add(const NotificationFilterEvent.toggleShowUnreadsOnly()), + ), + ], + ), + ), + ); + } +} + +class _ToggleUnreadsButton extends StatefulWidget { + const _ToggleUnreadsButton({ + required this.onToggled, + this.showUnreadsOnly = false, + }); + + final Function(bool) onToggled; + final bool showUnreadsOnly; + + @override + State<_ToggleUnreadsButton> createState() => _ToggleUnreadsButtonState(); +} + +class _ToggleUnreadsButtonState extends State<_ToggleUnreadsButton> { + late bool showUnreadsOnly = widget.showUnreadsOnly; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + onSelectionChanged: (Set newSelection) { + setState(() => showUnreadsOnly = newSelection.first); + widget.onToggled(showUnreadsOnly); + }, + showSelectedIcon: false, + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: MaterialStatePropertyAll( + BorderSide(color: Theme.of(context).dividerColor), + ), + shape: const MaterialStatePropertyAll( + RoundedRectangleBorder( + borderRadius: Corners.s6Border, + ), + ), + foregroundColor: MaterialStateProperty.resolveWith( + (state) { + if (state.contains(MaterialState.selected)) { + return Theme.of(context).colorScheme.onPrimary; + } + + return AFThemeExtension.of(context).textColor; + }, + ), + backgroundColor: MaterialStateProperty.resolveWith( + (state) { + if (state.contains(MaterialState.selected)) { + return Theme.of(context).colorScheme.primary; + } + + if (state.contains(MaterialState.hovered)) { + return AFThemeExtension.of(context).lightGreyHover; + } + + return Theme.of(context).cardColor; + }, + ), + ), + segments: [ + ButtonSegment( + value: false, + label: Text( + LocaleKeys.notificationHub_actions_showAll.tr(), + style: const TextStyle(fontSize: 12), + ), + ), + ButtonSegment( + value: true, + label: Text( + LocaleKeys.notificationHub_actions_showUnreads.tr(), + style: const TextStyle(fontSize: 12), + ), + ), + ], + selected: {showUnreadsOnly}, + ); + } +} + +class _MarkAsReadButton extends StatefulWidget { + final VoidCallback? onMarkAllRead; + + const _MarkAsReadButton({this.onMarkAllRead}); + + @override + State<_MarkAsReadButton> createState() => _MarkAsReadButtonState(); +} + +class _MarkAsReadButtonState extends State<_MarkAsReadButton> { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: widget.onMarkAllRead != null ? 1 : 0.5, + child: FlowyHover( + onHover: (isHovering) => setState(() => _isHovering = isHovering), + resetHoverOnRebuild: false, + child: FlowyTextButton( + LocaleKeys.notificationHub_actions_markAllRead.tr(), + fontColor: widget.onMarkAllRead != null && _isHovering + ? Theme.of(context).colorScheme.onSurface + : AFThemeExtension.of(context).textColor, + heading: FlowySvg( + FlowySvgs.checklist_s, + color: widget.onMarkAllRead != null && _isHovering + ? Theme.of(context).colorScheme.onSurface + : AFThemeExtension.of(context).textColor, + ), + hoverColor: widget.onMarkAllRead != null && _isHovering + ? Theme.of(context).colorScheme.primary + : null, + onPressed: widget.onMarkAllRead, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart index 87ac72ed9f..b8300cde23 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart @@ -4,6 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_configuration.dart import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -22,13 +23,13 @@ class NotificationItem extends StatefulWidget { required this.scheduled, required this.body, required this.isRead, - this.path, this.block, this.includeTime = false, this.readOnly = false, this.onAction, this.onDelete, this.onReadChanged, + this.view, }); final String reminderId; @@ -36,7 +37,7 @@ class NotificationItem extends StatefulWidget { final Int64 scheduled; final String body; final bool isRead; - final Future? path; + final ViewPB? view; /// If [block] is provided, then [body] will be shown only if /// [block] fails to fetch. @@ -64,7 +65,7 @@ class _NotificationItemState extends State { @override void initState() { super.initState(); - widget.path?.then((p) => path = p); + widget.block?.then((b) => path = b?.path.first); } @override @@ -80,117 +81,97 @@ class _NotificationItemState extends State { GestureDetector( onTap: () => widget.onAction?.call(path), child: AbsorbPointer( - child: Opacity( - opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, - child: DecoratedBox( - decoration: BoxDecoration( - color: _isHovering && widget.onAction != null - ? AFThemeExtension.of(context).lightGreyHover - : Colors.transparent, - border: widget.isRead || widget.readOnly - ? null - : Border( - left: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.primary, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: PlatformExtension.isMobile + ? BorderSide( + color: AFThemeExtension.of(context).calloutBGColor, + ) + : BorderSide.none, + ), + ), + child: Opacity( + opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: _isHovering && widget.onAction != null + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent, + border: widget.isRead || widget.readOnly + ? null + : Border( + left: BorderSide( + width: PlatformExtension.isMobile ? 4 : 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + FlowySvgs.time_s, + size: Size.square( + PlatformExtension.isMobile ? 24 : 20, + ), + color: AFThemeExtension.of(context).textColor, + ), + const HSpace(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + FlowyText.semibold( + widget.title, + fontSize: + PlatformExtension.isMobile ? 16 : 14, + color: AFThemeExtension.of(context).textColor, + ), + // TODO(Xazin): Relative time + FlowyText.regular( + '${_scheduledString( + widget.scheduled, + widget.includeTime, + )}${widget.view != null ? " - ${widget.view!.name}" : ""}', + fontSize: + PlatformExtension.isMobile ? 12 : 10, + ), + const VSpace(5), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + color: + Theme.of(context).colorScheme.surface, + ), + child: _NotificationContent( + block: widget.block, + body: widget.body, + ), + ), + ], ), ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - FlowySvgs.time_s, - size: const Size.square(20), - color: Theme.of(context).colorScheme.tertiary, - ), - const HSpace(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - FlowyText.semibold( - widget.title, - fontSize: 14, - color: Theme.of(context).colorScheme.tertiary, - ), - // TODO(Xazin): Relative time + View Name - FlowyText.regular( - _scheduledString( - widget.scheduled, - widget.includeTime, - ), - fontSize: 10, - ), - const VSpace(5), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: Corners.s8Border, - color: Theme.of(context).colorScheme.surface, - ), - child: FutureBuilder( - future: widget.block, - builder: (context, snapshot) { - if (snapshot.hasError || - !snapshot.hasData || - snapshot.data == null) { - return FlowyText.regular( - widget.body, - maxLines: 4, - ); - } - - final EditorState editorState = EditorState( - document: Document(root: snapshot.data!), - ); - - final EditorStyleCustomizer - styleCustomizer = EditorStyleCustomizer( - context: context, - padding: EdgeInsets.zero, - ); - - return Transform.scale( - scale: .9, - alignment: Alignment.centerLeft, - child: AppFlowyEditor( - editorState: editorState, - editorStyle: styleCustomizer.style(), - editable: false, - shrinkWrap: true, - blockComponentBuilders: - getEditorBuilderMap( - context: context, - editorState: editorState, - styleCustomizer: styleCustomizer, - editable: false, - ), - ), - ); - }, - ), - ), - ], - ), - ), - ], + ], + ), ), ), ), ), ), ), - if (_isHovering && !widget.readOnly) + if (PlatformExtension.isMobile && !widget.readOnly || + _isHovering && !widget.readOnly) Positioned( - right: 4, - top: 4, + right: PlatformExtension.isMobile ? 8 : 4, + top: PlatformExtension.isMobile ? 8 : 4, child: NotificationItemActions( isRead: widget.isRead, onDelete: widget.onDelete, @@ -214,6 +195,54 @@ class _NotificationItemState extends State { void _onHover(bool isHovering) => setState(() => _isHovering = isHovering); } +class _NotificationContent extends StatelessWidget { + const _NotificationContent({ + required this.body, + required this.block, + }); + + final String body; + final Future? block; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: block, + builder: (context, snapshot) { + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return FlowyText.regular(body, maxLines: 4); + } + + final editorState = EditorState( + document: Document(root: snapshot.data!), + ); + + final styleCustomizer = EditorStyleCustomizer( + context: context, + padding: EdgeInsets.zero, + ); + + return Transform.scale( + scale: .9, + alignment: Alignment.centerLeft, + child: AppFlowyEditor( + editorState: editorState, + editorStyle: styleCustomizer.style(), + editable: false, + shrinkWrap: true, + blockComponentBuilders: getEditorBuilderMap( + context: context, + editorState: editorState, + styleCustomizer: styleCustomizer, + editable: false, + ), + ), + ); + }, + ); + } +} + class NotificationItemActions extends StatelessWidget { const NotificationItemActions({ super.key, @@ -228,11 +257,15 @@ class NotificationItemActions extends StatelessWidget { @override Widget build(BuildContext context) { + final double size = PlatformExtension.isMobile ? 40.0 : 30.0; + return Container( - height: 30, + height: size, decoration: BoxDecoration( color: Theme.of(context).cardColor, - border: Border.all(color: Theme.of(context).dividerColor), + border: Border.all( + color: AFThemeExtension.of(context).lightGreyHover, + ), borderRadius: BorderRadius.circular(6), ), child: IntrinsicHeight( @@ -240,7 +273,8 @@ class NotificationItemActions extends StatelessWidget { children: [ if (isRead) ...[ FlowyIconButton( - height: 28, + height: size, + width: size, tooltipText: LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), icon: const FlowySvg(FlowySvgs.restore_s), @@ -249,7 +283,8 @@ class NotificationItemActions extends StatelessWidget { ), ] else ...[ FlowyIconButton( - height: 28, + height: size, + width: size, tooltipText: LocaleKeys.reminderNotification_tooltipMarkRead.tr(), iconColorOnHover: Theme.of(context).colorScheme.onSurface, @@ -262,10 +297,13 @@ class NotificationItemActions extends StatelessWidget { thickness: 1, indent: 2, endIndent: 2, - color: Theme.of(context).dividerColor, + color: PlatformExtension.isMobile + ? Theme.of(context).colorScheme.outline + : Theme.of(context).dividerColor, ), FlowyIconButton( - height: 28, + height: size, + width: size, tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(), icon: const FlowySvg(FlowySvgs.delete_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart index f1a1df1368..66eebe83f9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class NotificationTabBar extends StatelessWidget { @@ -36,11 +36,11 @@ class NotificationTabBar extends StatelessWidget { ), isScrollable: true, tabs: [ - _FlowyTab( + FlowyTabItem( label: LocaleKeys.notificationHub_tabs_inbox.tr(), isSelected: tabController.index == 0, ), - _FlowyTab( + FlowyTabItem( label: LocaleKeys.notificationHub_tabs_upcoming.tr(), isSelected: tabController.index == 1, ), @@ -52,27 +52,3 @@ class NotificationTabBar extends StatelessWidget { ); } } - -class _FlowyTab extends StatelessWidget { - final String label; - final bool isSelected; - - const _FlowyTab({ - required this.label, - required this.isSelected, - }); - - @override - Widget build(BuildContext context) { - return Tab( - height: 26, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: FlowyText.regular( - label, - color: isSelected ? Theme.of(context).colorScheme.tertiary : null, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart index d8314e4074..d33c449f55 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart @@ -9,9 +9,15 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter/material.dart'; +/// Displays a Lsit of Notifications, currently used primarily to +/// display Reminders. +/// +/// Optimized for both Mobile & Desktop use +/// class NotificationsView extends StatelessWidget { const NotificationsView({ super.key, @@ -29,7 +35,7 @@ class NotificationsView extends StatelessWidget { final ReminderBloc reminderBloc; final List views; final bool isUpcoming; - final Function(ReminderPB reminder, int? path)? onAction; + final Function(ReminderPB reminder, int? path, ViewPB? view)? onAction; final Function(ReminderPB reminder)? onDelete; final Function(ReminderPB reminder, bool isRead)? onReadChanged; final Widget? actionBar; @@ -46,47 +52,56 @@ class NotificationsView extends StatelessWidget { ); } - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (actionBar != null) actionBar!, - ...shownReminders.map( - (ReminderPB reminder) { - final blockId = reminder.meta[ReminderMetaKeys.blockId.name]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (actionBar != null) actionBar!, + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + ...shownReminders.map( + (ReminderPB reminder) { + final blockId = + reminder.meta[ReminderMetaKeys.blockId.name]; - final documentService = DocumentService(); - final documentFuture = documentService.openDocument( - viewId: reminder.objectId, - ); + final documentService = DocumentService(); + final documentFuture = documentService.openDocument( + viewId: reminder.objectId, + ); - Future? nodeBuilder; - Future? pathFinder; - if (blockId != null) { - nodeBuilder = _getNodeFromDocument(documentFuture, blockId); - pathFinder = _getPathFromDocument(documentFuture, blockId); - } + Future? nodeBuilder; + if (blockId != null) { + nodeBuilder = + _getNodeFromDocument(documentFuture, blockId); + } - return NotificationItem( - reminderId: reminder.id, - key: ValueKey(reminder.id), - title: reminder.title, - scheduled: reminder.scheduledAt, - body: reminder.message, - path: pathFinder, - block: nodeBuilder, - isRead: reminder.isRead, - includeTime: reminder.includeTime ?? false, - readOnly: isUpcoming, - onReadChanged: (isRead) => - onReadChanged?.call(reminder, isRead), - onDelete: () => onDelete?.call(reminder), - onAction: (path) => onAction?.call(reminder, path), - ); - }, + final view = views + .firstWhereOrNull((v) => v.id == reminder.objectId); + + return NotificationItem( + reminderId: reminder.id, + key: ValueKey(reminder.id), + title: reminder.title, + scheduled: reminder.scheduledAt, + body: reminder.message, + block: nodeBuilder, + isRead: reminder.isRead, + includeTime: reminder.includeTime ?? false, + readOnly: isUpcoming, + onReadChanged: (isRead) => + onReadChanged?.call(reminder, isRead), + onDelete: () => onDelete?.call(reminder), + onAction: (path) => onAction?.call(reminder, path, view), + view: view, + ); + }, + ), + ], + ), ), - ], - ), + ), + ], ); } @@ -103,36 +118,12 @@ class NotificationsView extends StatelessWidget { return null; } - final blockOrFailure = await DocumentService().getBlockFromDocument( - document: document, - blockId: blockId, - ); - - return blockOrFailure.fold( - (_) => null, - (block) => block.toNode(meta: MetaPB()), - ); - } - - Future _getPathFromDocument( - Future> documentFuture, - String blockId, - ) async { - final document = (await documentFuture).fold( - (l) => null, - (document) => document, - ); - - if (document == null) { - return null; - } - final rootNode = document.toDocument()?.root; if (rootNode == null) { return null; } - return _searchById(rootNode, blockId)?.path.first; + return _searchById(rootNode, blockId); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart index d1f87019d6..9e6276d278 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart @@ -23,6 +23,7 @@ class NotificationsHubEmpty extends StatelessWidget { const VSpace(8), FlowyText.regular( LocaleKeys.notificationHub_emptyBody.tr(), + textAlign: TextAlign.center, ), ], ), diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index c675e57278..3a287bfe5e 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -975,6 +975,9 @@ }, "notificationHub": { "title": "Notifications", + "mobile": { + "title": "Updates" + }, "emptyTitle": "All caught up!", "emptyBody": "No pending notifications or actions. Enjoy the calm.", "tabs": { @@ -1151,4 +1154,4 @@ "addField": "Add field", "userIcon": "User icon" } -} \ No newline at end of file +}