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
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'; import 'package:go_router/go_router.dart';
class MobileHomePageHeader extends StatelessWidget { class MobileHomePageHeader extends StatelessWidget {
const MobileHomePageHeader({ const MobileHomePageHeader({super.key, required this.userProfile});
super.key,
required this.userProfile,
});
final UserProfilePB userProfile; final UserProfilePB userProfile;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider( return BlocProvider(
create: (context) => getIt<SettingsUserViewBloc>(param1: userProfile) create: (context) => getIt<SettingsUserViewBloc>(param1: userProfile)
..add(const SettingsUserEvent.initial()), ..add(const SettingsUserEvent.initial()),
@ -41,29 +37,23 @@ class MobileHomePageHeader extends StatelessWidget {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const FlowyText.medium( const FlowyText.medium('AppFlowy', fontSize: 18),
'AppFlowy',
fontSize: 18,
),
const VSpace(4), const VSpace(4),
FlowyText.regular( FlowyText.regular(
userProfile.email.isNotEmpty userProfile.email.isNotEmpty
? userProfile.email ? state.userProfile.email
: userProfile.name, : state.userProfile.name,
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
IconButton( IconButton(
onPressed: () { onPressed: () =>
context.push(MobileHomeSettingPage.routeName); context.push(MobileHomeSettingPage.routeName),
}, icon: const FlowySvg(FlowySvgs.m_setting_m),
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( return EditUsernameBottomSheet(
context, context,
userName: userName, userName: userName,
onSubmitted: (value) { onSubmitted: (value) => context
context.read<SettingsUserViewBloc>().add( .read<SettingsUserViewBloc>()
SettingsUserEvent.updateUserName( .add(SettingsUserEvent.updateUserName(value)),
value,
),
);
},
); );
}, },
); );

View File

@ -87,39 +87,37 @@ class _DocumentPageState extends State<DocumentPage> {
child: BlocListener<NotificationActionBloc, NotificationActionState>( child: BlocListener<NotificationActionBloc, NotificationActionState>(
listener: _onNotificationAction, listener: _onNotificationAction,
child: BlocBuilder<DocumentBloc, DocumentState>( child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) { builder: (context, state) => state.loadingState.when(
return state.loadingState.when( loading: () =>
loading: () => const Center(child: CircularProgressIndicator.adaptive()),
const Center(child: CircularProgressIndicator.adaptive()), finish: (result) => result.fold(
finish: (result) => result.fold( (error) {
(error) { Log.error(error);
Log.error(error); return FlowyErrorPage.message(
return FlowyErrorPage.message( error.toString(),
error.toString(), howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
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),
),
); );
}, } else {
(data) { editorState = documentBloc.editorState!;
if (state.forceClose) { return _buildEditorPage(
widget.onDeleted(); context,
return const SizedBox.shrink(); state,
} else if (documentBloc.editorState == null) { );
return Center( }
child: ExportPageWidget( },
onTap: () async => await _exportPage(data), ),
), ),
);
} else {
editorState = documentBloc.editorState!;
return _buildEditorPage(
context,
state,
);
}
},
),
);
},
), ),
), ),
); );

View File

@ -46,10 +46,10 @@ class ErrorBlockComponentWidget extends BlockComponentStatefulWidget {
@override @override
State<ErrorBlockComponentWidget> createState() => State<ErrorBlockComponentWidget> createState() =>
_DividerBlockComponentWidgetState(); _ErrorBlockComponentWidgetState();
} }
class _DividerBlockComponentWidgetState extends State<ErrorBlockComponentWidget> class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
with BlockComponentConfigurable { with BlockComponentConfigurable {
@override @override
BlockComponentConfiguration get configuration => widget.configuration; 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/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_settings_service.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/notifications/notification_service.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart';
@ -137,18 +140,44 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
BlocProvider<DocumentAppearanceCubit>( BlocProvider<DocumentAppearanceCubit>(
create: (_) => DocumentAppearanceCubit()..fetch(), create: (_) => DocumentAppearanceCubit()..fetch(),
), ),
BlocProvider.value(value: getIt<NotificationActionBloc>()),
], ],
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>( child: BlocListener<NotificationActionBloc, NotificationActionState>(
builder: (context, state) => MaterialApp.router( listener: (context, state) {
builder: overlayManagerBuilder(), if (state.action?.type == ActionType.openView) {
debugShowCheckedModeBanner: false, WidgetsBinding.instance.addPostFrameCallback((_) {
theme: state.lightTheme, final view =
darkTheme: state.darkTheme, state.action!.arguments?[ActionArgumentKeys.view.name];
themeMode: state.themeMode, if (view != null) {
localizationsDelegates: context.localizationDelegates, AppGlobals.rootNavKey.currentContext?.pushView(view);
supportedLocales: context.supportedLocales,
locale: state.locale, final nodePath = state.action!
routerConfig: routerConfig, .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 { class ApplicationBlocObserver extends BlocObserver {
@override @override
// ignore: unnecessary_overrides
void onTransition(Bloc bloc, Transition transition) { void onTransition(Bloc bloc, Transition transition) {
// Log.debug("[current]: ${transition.currentState} \n\n[next]: ${transition.nextState}"); // Log.debug("[current]: ${transition.currentState} \n\n[next]: ${transition.nextState}");
// Log.debug("${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_calendar_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_grid_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/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/presentation.dart';
import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart';
import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart';
@ -181,21 +182,8 @@ StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() {
StatefulShellBranch( StatefulShellBranch(
routes: <RouteBase>[ routes: <RouteBase>[
GoRoute( GoRoute(
path: '/e', path: MobileNotificationsScreen.routeName,
builder: (BuildContext context, GoRouterState state) => builder: (_, __) => const MobileNotificationsScreen(),
const RootPlaceholderScreen(
label: 'Notification',
detailsPath: '/e/details',
),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) =>
const DetailsPlaceholderScreen(
label: 'Notification Page details',
),
),
],
), ),
], ],
), ),
@ -492,12 +480,8 @@ GoRoute _mobileEditorScreenRoute() {
pageBuilder: (context, state) { pageBuilder: (context, state) {
final id = state.uri.queryParameters[MobileEditorScreen.viewId]!; final id = state.uri.queryParameters[MobileEditorScreen.viewId]!;
final title = state.uri.queryParameters[MobileEditorScreen.viewTitle]; final title = state.uri.queryParameters[MobileEditorScreen.viewTitle];
return MaterialPage(
child: MobileEditorScreen( return MaterialPage(child: MobileEditorScreen(id: id, title: title));
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_action_bloc.dart';
import 'package:appflowy/workspace/application/notifications/notification_service.dart'; import 'package:appflowy/workspace/application/notifications/notification_service.dart';
import 'package:appflowy_backend/log.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:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.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 = final reminder =
state.reminders.firstWhereOrNull((r) => r.id == reminderId); state.reminders.firstWhereOrNull((r) => r.id == reminderId);
@ -129,6 +130,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
objectId: reminder.objectId, objectId: reminder.objectId,
arguments: { arguments: {
ActionArgumentKeys.nodePath.name: path, ActionArgumentKeys.nodePath.name: path,
ActionArgumentKeys.view.name: view,
}, },
), ),
), ),
@ -201,6 +203,7 @@ class ReminderEvent with _$ReminderEvent {
const factory ReminderEvent.pressReminder({ const factory ReminderEvent.pressReminder({
required String reminderId, required String reminderId,
@Default(null) int? path, @Default(null) int? path,
@Default(null) ViewPB? view,
}) = _PressReminder; }) = _PressReminder;
} }

View File

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

View File

@ -154,7 +154,7 @@ class SettingsUserState with _$SettingsUserState {
factory SettingsUserState.initial(UserProfilePB userProfile) => factory SettingsUserState.initial(UserProfilePB userProfile) =>
SettingsUserState( SettingsUserState(
userProfile: userProfile, userProfile: userProfile,
historicalUsers: [], historicalUsers: const [],
successOrFailure: left(unit), 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/startup/startup.dart';
import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_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_hub_title.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.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/workspace/presentation/notifications/widgets/notification_view.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.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_backend/protobuf/flowy-user/reminder.pb.dart';
import 'package:appflowy_popover/appflowy_popover.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/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 { class NotificationDialog extends StatefulWidget {
const NotificationDialog({ const NotificationDialog({
super.key, super.key,
@ -98,7 +87,7 @@ class _NotificationDialogState extends State<NotificationDialog>
onDelete: _onDelete, onDelete: _onDelete,
onAction: _onAction, onAction: _onAction,
onReadChanged: _onReadChanged, onReadChanged: _onReadChanged,
actionBar: _InboxActionBar( actionBar: InboxActionBar(
hasUnreads: state.hasUnreads, hasUnreads: state.hasUnreads,
showUnreadsOnly: filterState.showUnreadsOnly, 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( _reminderBloc.add(
ReminderEvent.pressReminder(reminderId: reminder.id, path: path), 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/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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/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_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -22,13 +23,13 @@ class NotificationItem extends StatefulWidget {
required this.scheduled, required this.scheduled,
required this.body, required this.body,
required this.isRead, required this.isRead,
this.path,
this.block, this.block,
this.includeTime = false, this.includeTime = false,
this.readOnly = false, this.readOnly = false,
this.onAction, this.onAction,
this.onDelete, this.onDelete,
this.onReadChanged, this.onReadChanged,
this.view,
}); });
final String reminderId; final String reminderId;
@ -36,7 +37,7 @@ class NotificationItem extends StatefulWidget {
final Int64 scheduled; final Int64 scheduled;
final String body; final String body;
final bool isRead; final bool isRead;
final Future<int?>? path; final ViewPB? view;
/// If [block] is provided, then [body] will be shown only if /// If [block] is provided, then [body] will be shown only if
/// [block] fails to fetch. /// [block] fails to fetch.
@ -64,7 +65,7 @@ class _NotificationItemState extends State<NotificationItem> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
widget.path?.then((p) => path = p); widget.block?.then((b) => path = b?.path.first);
} }
@override @override
@ -80,117 +81,97 @@ class _NotificationItemState extends State<NotificationItem> {
GestureDetector( GestureDetector(
onTap: () => widget.onAction?.call(path), onTap: () => widget.onAction?.call(path),
child: AbsorbPointer( child: AbsorbPointer(
child: Opacity( child: DecoratedBox(
opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, decoration: BoxDecoration(
child: DecoratedBox( border: Border(
decoration: BoxDecoration( bottom: PlatformExtension.isMobile
color: _isHovering && widget.onAction != null ? BorderSide(
? AFThemeExtension.of(context).lightGreyHover color: AFThemeExtension.of(context).calloutBGColor,
: Colors.transparent, )
border: widget.isRead || widget.readOnly : BorderSide.none,
? null ),
: Border( ),
left: BorderSide( child: Opacity(
width: 2, opacity: widget.isRead && !widget.readOnly ? 0.5 : 1,
color: Theme.of(context).colorScheme.primary, 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( Positioned(
right: 4, right: PlatformExtension.isMobile ? 8 : 4,
top: 4, top: PlatformExtension.isMobile ? 8 : 4,
child: NotificationItemActions( child: NotificationItemActions(
isRead: widget.isRead, isRead: widget.isRead,
onDelete: widget.onDelete, onDelete: widget.onDelete,
@ -214,6 +195,54 @@ class _NotificationItemState extends State<NotificationItem> {
void _onHover(bool isHovering) => setState(() => _isHovering = isHovering); 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 { class NotificationItemActions extends StatelessWidget {
const NotificationItemActions({ const NotificationItemActions({
super.key, super.key,
@ -228,11 +257,15 @@ class NotificationItemActions extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double size = PlatformExtension.isMobile ? 40.0 : 30.0;
return Container( return Container(
height: 30, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, 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), borderRadius: BorderRadius.circular(6),
), ),
child: IntrinsicHeight( child: IntrinsicHeight(
@ -240,7 +273,8 @@ class NotificationItemActions extends StatelessWidget {
children: [ children: [
if (isRead) ...[ if (isRead) ...[
FlowyIconButton( FlowyIconButton(
height: 28, height: size,
width: size,
tooltipText: tooltipText:
LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), LocaleKeys.reminderNotification_tooltipMarkUnread.tr(),
icon: const FlowySvg(FlowySvgs.restore_s), icon: const FlowySvg(FlowySvgs.restore_s),
@ -249,7 +283,8 @@ class NotificationItemActions extends StatelessWidget {
), ),
] else ...[ ] else ...[
FlowyIconButton( FlowyIconButton(
height: 28, height: size,
width: size,
tooltipText: tooltipText:
LocaleKeys.reminderNotification_tooltipMarkRead.tr(), LocaleKeys.reminderNotification_tooltipMarkRead.tr(),
iconColorOnHover: Theme.of(context).colorScheme.onSurface, iconColorOnHover: Theme.of(context).colorScheme.onSurface,
@ -262,10 +297,13 @@ class NotificationItemActions extends StatelessWidget {
thickness: 1, thickness: 1,
indent: 2, indent: 2,
endIndent: 2, endIndent: 2,
color: Theme.of(context).dividerColor, color: PlatformExtension.isMobile
? Theme.of(context).colorScheme.outline
: Theme.of(context).dividerColor,
), ),
FlowyIconButton( FlowyIconButton(
height: 28, height: size,
width: size,
tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(), tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(),
icon: const FlowySvg(FlowySvgs.delete_s), icon: const FlowySvg(FlowySvgs.delete_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface, iconColorOnHover: Theme.of(context).colorScheme.onSurface,

View File

@ -1,6 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart'; 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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class NotificationTabBar extends StatelessWidget { class NotificationTabBar extends StatelessWidget {
@ -36,11 +36,11 @@ class NotificationTabBar extends StatelessWidget {
), ),
isScrollable: true, isScrollable: true,
tabs: [ tabs: [
_FlowyTab( FlowyTabItem(
label: LocaleKeys.notificationHub_tabs_inbox.tr(), label: LocaleKeys.notificationHub_tabs_inbox.tr(),
isSelected: tabController.index == 0, isSelected: tabController.index == 0,
), ),
_FlowyTab( FlowyTabItem(
label: LocaleKeys.notificationHub_tabs_upcoming.tr(), label: LocaleKeys.notificationHub_tabs_upcoming.tr(),
isSelected: tabController.index == 1, 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-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
import 'package:flutter/material.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 { class NotificationsView extends StatelessWidget {
const NotificationsView({ const NotificationsView({
super.key, super.key,
@ -29,7 +35,7 @@ class NotificationsView extends StatelessWidget {
final ReminderBloc reminderBloc; final ReminderBloc reminderBloc;
final List<ViewPB> views; final List<ViewPB> views;
final bool isUpcoming; 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)? onDelete;
final Function(ReminderPB reminder, bool isRead)? onReadChanged; final Function(ReminderPB reminder, bool isRead)? onReadChanged;
final Widget? actionBar; final Widget? actionBar;
@ -46,47 +52,56 @@ class NotificationsView extends StatelessWidget {
); );
} }
return SingleChildScrollView( return Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ if (actionBar != null) actionBar!,
if (actionBar != null) actionBar!, Expanded(
...shownReminders.map( child: SingleChildScrollView(
(ReminderPB reminder) { child: Column(
final blockId = reminder.meta[ReminderMetaKeys.blockId.name]; children: [
...shownReminders.map(
(ReminderPB reminder) {
final blockId =
reminder.meta[ReminderMetaKeys.blockId.name];
final documentService = DocumentService(); final documentService = DocumentService();
final documentFuture = documentService.openDocument( final documentFuture = documentService.openDocument(
viewId: reminder.objectId, viewId: reminder.objectId,
); );
Future<Node?>? nodeBuilder; Future<Node?>? nodeBuilder;
Future<int?>? pathFinder; if (blockId != null) {
if (blockId != null) { nodeBuilder =
nodeBuilder = _getNodeFromDocument(documentFuture, blockId); _getNodeFromDocument(documentFuture, blockId);
pathFinder = _getPathFromDocument(documentFuture, blockId); }
}
return NotificationItem( final view = views
reminderId: reminder.id, .firstWhereOrNull((v) => v.id == reminder.objectId);
key: ValueKey(reminder.id),
title: reminder.title, return NotificationItem(
scheduled: reminder.scheduledAt, reminderId: reminder.id,
body: reminder.message, key: ValueKey(reminder.id),
path: pathFinder, title: reminder.title,
block: nodeBuilder, scheduled: reminder.scheduledAt,
isRead: reminder.isRead, body: reminder.message,
includeTime: reminder.includeTime ?? false, block: nodeBuilder,
readOnly: isUpcoming, isRead: reminder.isRead,
onReadChanged: (isRead) => includeTime: reminder.includeTime ?? false,
onReadChanged?.call(reminder, isRead), readOnly: isUpcoming,
onDelete: () => onDelete?.call(reminder), onReadChanged: (isRead) =>
onAction: (path) => onAction?.call(reminder, path), 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; 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; final rootNode = document.toDocument()?.root;
if (rootNode == null) { if (rootNode == null) {
return 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), const VSpace(8),
FlowyText.regular( FlowyText.regular(
LocaleKeys.notificationHub_emptyBody.tr(), LocaleKeys.notificationHub_emptyBody.tr(),
textAlign: TextAlign.center,
), ),
], ],
), ),

View File

@ -975,6 +975,9 @@
}, },
"notificationHub": { "notificationHub": {
"title": "Notifications", "title": "Notifications",
"mobile": {
"title": "Updates"
},
"emptyTitle": "All caught up!", "emptyTitle": "All caught up!",
"emptyBody": "No pending notifications or actions. Enjoy the calm.", "emptyBody": "No pending notifications or actions. Enjoy the calm.",
"tabs": { "tabs": {
@ -1151,4 +1154,4 @@
"addField": "Add field", "addField": "Add field",
"userIcon": "User icon" "userIcon": "User icon"
} }
} }