feat: mobile notifications screen (#4100)

* fix: update username on mobile header on change

* feat: notifications page

* feat: refactor and refinement

* fix: code review
This commit is contained in:
Mathias Mogensen 2023-12-08 15:04:09 +02:00 committed by GitHub
parent 2a421034d9
commit 649545cdf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 828 additions and 473 deletions

View File

@ -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<UserProfileEvent, UserProfileState> {
UserProfileBloc() : super(const _Initial()) {
on<UserProfileEvent>((event, emit) async {
await event.when(
started: () async => _initalize(emit),
);
});
}
Future<void> _initalize(Emitter<UserProfileState> emit) async {
emit(const UserProfileState.loading());
final workspaceOrFailure =
await FolderEventGetCurrentWorkspaceSetting().send();
final userOrFailure = await getIt<AuthService>().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;
}

View File

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

View File

@ -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<MobileNotificationsScreen> createState() =>
_MobileNotificationsScreenState();
}
class _MobileNotificationsScreenState extends State<MobileNotificationsScreen>
with SingleTickerProviderStateMixin {
final ReminderBloc _reminderBloc = getIt<ReminderBloc>();
late final TabController _controller = TabController(length: 2, vsync: this);
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<UserProfileBloc>(
create: (context) =>
UserProfileBloc()..add(const UserProfileEvent.started()),
),
BlocProvider<ReminderBloc>.value(value: _reminderBloc),
BlocProvider<NotificationFilterBloc>(
create: (_) => NotificationFilterBloc(),
),
],
child: BlocBuilder<UserProfileBloc, UserProfileState>(
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<MenuBloc, MenuState>(
builder: (context, menuState) =>
BlocBuilder<NotificationFilterBloc, NotificationFilterState>(
builder: (context, filterState) =>
BlocBuilder<ReminderBloc, ReminderState>(
builder: (context, state) {
// Workaround for rebuilding the Blocks by brightness
Theme.of(context).brightness;
final List<ReminderPB> pastReminders = state.pastReminders
.where(
(r) => filterState.showUnreadsOnly ? !r.isRead : true,
)
.sortByScheduledAt();
final List<ReminderPB> 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)),
);
}

View File

@ -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<MobileNotificationTabBar> createState() =>
_MobileNotificationTabBarState();
}
class _MobileNotificationTabBarState extends State<MobileNotificationTabBar> {
@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,
),
],
),
),
],
),
);
}
}

View File

@ -50,13 +50,9 @@ class PersonalInfoSettingGroup extends StatelessWidget {
return EditUsernameBottomSheet(
context,
userName: userName,
onSubmitted: (value) {
context.read<SettingsUserViewBloc>().add(
SettingsUserEvent.updateUserName(
value,
),
);
},
onSubmitted: (value) => context
.read<SettingsUserViewBloc>()
.add(SettingsUserEvent.updateUserName(value)),
);
},
);

View File

@ -87,39 +87,37 @@ class _DocumentPageState extends State<DocumentPage> {
child: BlocListener<NotificationActionBloc, NotificationActionState>(
listener: _onNotificationAction,
child: BlocBuilder<DocumentBloc, DocumentState>(
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,
);
}
},
),
),
),
),
);

View File

@ -46,10 +46,10 @@ class ErrorBlockComponentWidget extends BlockComponentStatefulWidget {
@override
State<ErrorBlockComponentWidget> createState() =>
_DividerBlockComponentWidgetState();
_ErrorBlockComponentWidgetState();
}
class _DividerBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;

View File

@ -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<ApplicationWidget> {
BlocProvider<DocumentAppearanceCubit>(
create: (_) => DocumentAppearanceCubit()..fetch(),
),
BlocProvider.value(value: getIt<NotificationActionBloc>()),
],
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
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<NotificationActionBloc, NotificationActionState>(
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<NotificationActionBloc>().add(
NotificationActionEvent.performAction(
action: state.action!
.copyWith(type: ActionType.jumpToBlock),
),
);
}
}
});
}
},
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
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}");

View File

@ -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: <RouteBase>[
GoRoute(
path: '/e',
builder: (BuildContext context, GoRouterState state) =>
const RootPlaceholderScreen(
label: 'Notification',
detailsPath: '/e/details',
),
routes: <RouteBase>[
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));
},
);
}

View File

@ -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<ReminderEvent, ReminderState> {
},
);
},
pressReminder: (reminderId, path) {
pressReminder: (reminderId, path, view) {
final reminder =
state.reminders.firstWhereOrNull((r) => r.id == reminderId);
@ -129,6 +130,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
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;
}

View File

@ -33,6 +33,7 @@ class NotificationAction {
}
enum ActionArgumentKeys {
view('view'),
nodePath('node_path');
final String name;

View File

@ -154,7 +154,7 @@ class SettingsUserState with _$SettingsUserState {
factory SettingsUserState.initial(UserProfilePB userProfile) =>
SettingsUserState(
userProfile: userProfile,
historicalUsers: [],
historicalUsers: const [],
successOrFailure: left(unit),
);
}

View File

@ -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<ReminderPB> {
List<ReminderPB> 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<NotificationDialog>
onDelete: _onDelete,
onAction: _onAction,
onReadChanged: _onReadChanged,
actionBar: _InboxActionBar(
actionBar: InboxActionBar(
hasUnreads: state.hasUnreads,
showUnreadsOnly: filterState.showUnreadsOnly,
),
@ -121,7 +110,7 @@ class _NotificationDialogState extends State<NotificationDialog>
);
}
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<NotificationDialog>
);
}
}
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<ReminderBloc>()
.add(const ReminderEvent.markAllRead()),
),
_ToggleUnreadsButton(
showUnreadsOnly: showUnreadsOnly,
onToggled: (showUnreadsOnly) => context
.read<NotificationFilterBloc>()
.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<bool>(
onSelectionChanged: (Set<bool> 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<Color>(
(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<Color>(
(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<bool>(
value: false,
label: Text(
LocaleKeys.notificationHub_actions_showAll.tr(),
style: const TextStyle(fontSize: 12),
),
),
ButtonSegment<bool>(
value: true,
label: Text(
LocaleKeys.notificationHub_actions_showUnreads.tr(),
style: const TextStyle(fontSize: 12),
),
),
],
selected: <bool>{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,
),
),
);
}
}

View File

@ -0,0 +1,7 @@
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
import 'package:collection/collection.dart';
extension ReminderSort on Iterable<ReminderPB> {
List<ReminderPB> sortByScheduledAt() =>
sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt));
}

View File

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

View File

@ -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<ReminderBloc>()
.add(const ReminderEvent.markAllRead()),
),
_ToggleUnreadsButton(
showUnreadsOnly: showUnreadsOnly,
onToggled: (_) => context
.read<NotificationFilterBloc>()
.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<bool>(
onSelectionChanged: (Set<bool> 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<Color>(
(state) {
if (state.contains(MaterialState.selected)) {
return Theme.of(context).colorScheme.onPrimary;
}
return AFThemeExtension.of(context).textColor;
},
),
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(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<bool>(
value: false,
label: Text(
LocaleKeys.notificationHub_actions_showAll.tr(),
style: const TextStyle(fontSize: 12),
),
),
ButtonSegment<bool>(
value: true,
label: Text(
LocaleKeys.notificationHub_actions_showUnreads.tr(),
style: const TextStyle(fontSize: 12),
),
),
],
selected: <bool>{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,
),
),
);
}
}

View File

@ -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<int?>? 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<NotificationItem> {
@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<NotificationItem> {
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<Node?>(
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<NotificationItem> {
void _onHover(bool isHovering) => setState(() => _isHovering = isHovering);
}
class _NotificationContent extends StatelessWidget {
const _NotificationContent({
required this.body,
required this.block,
});
final String body;
final Future<Node?>? block;
@override
Widget build(BuildContext context) {
return FutureBuilder<Node?>(
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,

View File

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

View File

@ -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<ViewPB> 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<Node?>? nodeBuilder;
Future<int?>? pathFinder;
if (blockId != null) {
nodeBuilder = _getNodeFromDocument(documentFuture, blockId);
pathFinder = _getPathFromDocument(documentFuture, blockId);
}
Future<Node?>? 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<int?> _getPathFromDocument(
Future<Either<FlowyError, DocumentDataPB>> 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);
}
}

View File

@ -23,6 +23,7 @@ class NotificationsHubEmpty extends StatelessWidget {
const VSpace(8),
FlowyText.regular(
LocaleKeys.notificationHub_emptyBody.tr(),
textAlign: TextAlign.center,
),
],
),

View File

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