diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 89c687bba7..0ffe9423d1 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.6.6" +APPFLOWY_VERSION = "0.6.7" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart index ac81ba2122..d0e973ae64 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart @@ -9,7 +9,7 @@ class AnimatedGestureDetector extends StatefulWidget { this.duration = const Duration(milliseconds: 100), this.alignment = Alignment.center, this.behavior = HitTestBehavior.opaque, - required this.onTapUp, + this.onTapUp, required this.child, }); @@ -19,7 +19,7 @@ class AnimatedGestureDetector extends StatefulWidget { final Alignment alignment; final bool feedback; final HitTestBehavior behavior; - final VoidCallback onTapUp; + final VoidCallback? onTapUp; @override State createState() => @@ -38,7 +38,7 @@ class _AnimatedGestureDetectorState extends State { HapticFeedbackType.light.call(); - widget.onTapUp(); + widget.onTapUp?.call(); }, onTapDown: (details) { setState(() => scale = widget.scaleFactor); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart index a998adddc2..538ec0a354 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -1,17 +1,30 @@ import 'dart:ui'; import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +enum BottomNavigationBarActionType { + home, + notificationMultiSelect, +} + final PropertyValueNotifier createNewPageNotifier = PropertyValueNotifier(null); +final ValueNotifier bottomNavigationBarType = + ValueNotifier(BottomNavigationBarActionType.home); const _homeLabel = 'home'; const _addLabel = 'add'; @@ -37,7 +50,7 @@ final _items = [ /// Builds the "shell" for the app by building a Scaffold with a /// BottomNavigationBar, where [child] is placed in the body of the Scaffold. -class MobileBottomNavigationBar extends StatelessWidget { +class MobileBottomNavigationBar extends StatefulWidget { /// Constructs an [MobileBottomNavigationBar]. const MobileBottomNavigationBar({ required this.navigationShell, @@ -47,68 +60,76 @@ class MobileBottomNavigationBar extends StatelessWidget { /// The navigation shell and container for the branch Navigators. final StatefulNavigationShell navigationShell; + @override + State createState() => + _MobileBottomNavigationBarState(); +} + +class _MobileBottomNavigationBarState extends State { + Widget? _bottomNavigationBar; + + @override + void initState() { + super.initState(); + + bottomNavigationBarType.addListener(_animate); + } + + @override + void dispose() { + bottomNavigationBarType.removeListener(_animate); + super.dispose(); + } + @override Widget build(BuildContext context) { - final isLightMode = Theme.of(context).isLightMode; - final backgroundColor = isLightMode - ? Colors.white.withOpacity(0.95) - : const Color(0xFF23262B).withOpacity(0.95); - final borderColor = isLightMode - ? const Color(0x141F2329) - : const Color(0xFF23262B).withOpacity(0.5); + _bottomNavigationBar = switch (bottomNavigationBarType.value) { + BottomNavigationBarActionType.home => + _buildHomePageNavigationBar(context), + BottomNavigationBarActionType.notificationMultiSelect => + _buildNotificationNavigationBar(context), + }; + return Scaffold( - body: navigationShell, + body: widget.navigationShell, extendBody: true, - bottomNavigationBar: ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 3, - sigmaY: 3, - ), - child: DecoratedBox( - decoration: BoxDecoration( - border: isLightMode - ? Border(top: BorderSide(color: borderColor)) - : null, - color: backgroundColor, - ), - child: BottomNavigationBar( - showSelectedLabels: false, - showUnselectedLabels: false, - enableFeedback: false, - type: BottomNavigationBarType.fixed, - elevation: 0, - items: _items, - backgroundColor: Colors.transparent, - currentIndex: navigationShell.currentIndex, - onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex), - ), - ), - ), + bottomNavigationBar: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: _transitionBuilder, + child: _bottomNavigationBar, ), ); } - /// Navigate to the current location of the branch at the provided index when - /// tapping an item in the BottomNavigationBar. - void _onTap(BuildContext context, int bottomBarIndex) { - if (_items[bottomBarIndex].label == _addLabel) { - // show an add dialog - createNewPageNotifier.value = ViewLayoutPB.Document; - return; - } - // When navigating to a new branch, it's recommended to use the goBranch - // method, as doing so makes sure the last navigation state of the - // Navigator for the branch is restored. - navigationShell.goBranch( - bottomBarIndex, - // A common pattern when using bottom navigation bars is to support - // navigating to the initial location when tapping the item that is - // already active. This example demonstrates how to support this behavior, - // using the initialLocation parameter of goBranch. - initialLocation: bottomBarIndex == navigationShell.currentIndex, + Widget _buildHomePageNavigationBar(BuildContext context) { + return _HomePageNavigationBar( + navigationShell: widget.navigationShell, ); } + + Widget _buildNotificationNavigationBar(BuildContext context) { + return const _NotificationNavigationBar(); + } + + // widget A going down, widget B going up + Widget _transitionBuilder( + Widget child, + Animation animation, + ) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(animation), + child: child, + ); + } + + void _animate() { + setState(() {}); + } } class _NotificationNavigationBarItemIcon extends StatelessWidget { @@ -169,3 +190,170 @@ class _RedDot extends StatelessWidget { ); } } + +class _HomePageNavigationBar extends StatelessWidget { + const _HomePageNavigationBar({ + required this.navigationShell, + }); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 3, + sigmaY: 3, + ), + child: DecoratedBox( + decoration: BoxDecoration( + border: context.border, + color: context.backgroundColor, + ), + child: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + enableFeedback: false, + type: BottomNavigationBarType.fixed, + elevation: 0, + items: _items, + backgroundColor: Colors.transparent, + currentIndex: navigationShell.currentIndex, + onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex), + ), + ), + ), + ); + } + + /// Navigate to the current location of the branch at the provided index when + /// tapping an item in the BottomNavigationBar. + void _onTap(BuildContext context, int bottomBarIndex) { + if (_items[bottomBarIndex].label == _addLabel) { + // show an add dialog + createNewPageNotifier.value = ViewLayoutPB.Document; + return; + } + // When navigating to a new branch, it's recommended to use the goBranch + // method, as doing so makes sure the last navigation state of the + // Navigator for the branch is restored. + navigationShell.goBranch( + bottomBarIndex, + // A common pattern when using bottom navigation bars is to support + // navigating to the initial location when tapping the item that is + // already active. This example demonstrates how to support this behavior, + // using the initialLocation parameter of goBranch. + initialLocation: bottomBarIndex == navigationShell.currentIndex, + ); + } +} + +class _NotificationNavigationBar extends StatelessWidget { + const _NotificationNavigationBar(); + + @override + Widget build(BuildContext context) { + return Container( + // todo: use real height here. + height: 90, + decoration: BoxDecoration( + border: context.border, + color: context.backgroundColor, + ), + padding: const EdgeInsets.only(bottom: 20), + child: ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (context, value, child) { + if (value.isEmpty) { + // not editable + return IgnorePointer( + child: Opacity( + opacity: 0.3, + child: child, + ), + ); + } + + return child!; + }, + child: Row( + children: [ + const HSpace(20), + Expanded( + child: NavigationBarButton( + icon: FlowySvgs.m_notification_action_mark_as_read_s, + text: LocaleKeys.settings_notifications_action_markAsRead.tr(), + onTap: () => _onMarkAsRead(context), + ), + ), + const HSpace(16), + Expanded( + child: NavigationBarButton( + icon: FlowySvgs.m_notification_action_archive_s, + text: LocaleKeys.settings_notifications_action_archive.tr(), + onTap: () => _onArchive(context), + ), + ), + const HSpace(20), + ], + ), + ), + ); + } + + void _onMarkAsRead(BuildContext context) { + if (mSelectedNotificationIds.value.isEmpty) { + return; + } + + showToastNotification( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_allSuccess + .tr(), + ); + + getIt() + .add(ReminderEvent.markAsRead(mSelectedNotificationIds.value)); + + mSelectedNotificationIds.value = []; + } + + void _onArchive(BuildContext context) { + if (mSelectedNotificationIds.value.isEmpty) { + return; + } + + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess + .tr(), + ); + + getIt() + .add(ReminderEvent.archive(mSelectedNotificationIds.value)); + + mSelectedNotificationIds.value = []; + } +} + +extension on BuildContext { + Color get backgroundColor { + return Theme.of(this).isLightMode + ? Colors.white.withOpacity(0.95) + : const Color(0xFF23262B).withOpacity(0.95); + } + + Color get borderColor { + return Theme.of(this).isLightMode + ? const Color(0x141F2329) + : const Color(0xFF23262B).withOpacity(0.5); + } + + Border? get border { + return Theme.of(this).isLightMode + ? Border(top: BorderSide(color: borderColor)) + : null; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart new file mode 100644 index 0000000000..cf7ce35e80 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileNotificationsMultiSelectScreen extends StatelessWidget { + const MobileNotificationsMultiSelectScreen({super.key}); + + static const routeName = '/notifications_multi_select'; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: getIt(), + child: const MobileNotificationMultiSelect(), + ); + } +} + +class MobileNotificationMultiSelect extends StatefulWidget { + const MobileNotificationMultiSelect({ + super.key, + }); + + @override + State createState() => + _MobileNotificationMultiSelectState(); +} + +class _MobileNotificationMultiSelectState + extends State { + @override + void dispose() { + mSelectedNotificationIds.value.clear(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MobileNotificationMultiSelectPageHeader(), + VSpace(12.0), + Expanded( + child: MultiSelectNotificationTab(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart index ba0fae9fa8..2910740075 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart @@ -1,13 +1,17 @@ import 'package:appflowy/mobile/application/user_profile/user_profile_bloc.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/presentation.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/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +final PropertyValueNotifier> mSelectedNotificationIds = + PropertyValueNotifier([]); + class MobileNotificationsScreenV2 extends StatefulWidget { const MobileNotificationsScreenV2({super.key}); @@ -20,29 +24,33 @@ class MobileNotificationsScreenV2 extends StatefulWidget { class _MobileNotificationsScreenV2State extends State - with SingleTickerProviderStateMixin { + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); + return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => - UserProfileBloc()..add(const UserProfileEvent.started()), + create: (context) => UserProfileBloc() + ..add( + const UserProfileEvent.started(), + ), ), BlocProvider.value(value: getIt()), - BlocProvider( - create: (_) => NotificationFilterBloc(), - ), ], - child: BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - orElse: () => - const Center(child: CircularProgressIndicator.adaptive()), - workspaceFailure: () => const WorkspaceFailedScreen(), - success: (workspaceSetting, userProfile) => - const MobileNotificationsTab(), - ); + child: ValueListenableBuilder( + valueListenable: bottomNavigationBarType, + builder: (_, value, __) { + switch (value) { + case BottomNavigationBarActionType.home: + return const MobileNotificationsTab(); + case BottomNavigationBarActionType.notificationMultiSelect: + return const MobileNotificationMultiSelect(); + } }, ), ); @@ -52,11 +60,8 @@ class _MobileNotificationsScreenV2State class MobileNotificationsTab extends StatefulWidget { const MobileNotificationsTab({ super.key, - // required this.userProfile, }); - // final UserProfilePB userProfile; - @override State createState() => _MobileNotificationsTabState(); } @@ -79,12 +84,10 @@ class _MobileNotificationsTabState extends State length: 3, vsync: this, ); - tabController.addListener(_onTabChange); } @override void dispose() { - tabController.removeListener(_onTabChange); tabController.dispose(); super.dispose(); @@ -114,6 +117,4 @@ class _MobileNotificationsTabState extends State ), ); } - - void _onTabChange() {} } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart index 567846fb9c..c42fc48cd3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart @@ -1,5 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/settings_popup_menu.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -30,3 +32,65 @@ class MobileNotificationPageHeader extends StatelessWidget { ); } } + +class MobileNotificationMultiSelectPageHeader extends StatelessWidget { + const MobileNotificationMultiSelectPageHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildCancelButton( + isOpaque: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () => bottomNavigationBarType.value = + BottomNavigationBarActionType.home, + ), + ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (_, value, __) { + return FlowyText( + // todo: i18n + '${value.length} Selected', + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + ); + }, + ), + // this button is used to align the text to the center + _buildCancelButton( + isOpaque: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ], + ), + ); + } + + // + Widget _buildCancelButton({ + required bool isOpaque, + required EdgeInsets padding, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Padding( + padding: padding, + child: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: isOpaque ? Colors.transparent : null, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart new file mode 100644 index 0000000000..448f2033f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/gesture.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MultiSelectNotificationItem extends StatelessWidget { + const MultiSelectNotificationItem({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + final settings = context.read().state; + final dateFormate = settings.dateFormat; + final timeFormate = settings.timeFormat; + return BlocProvider( + create: (context) => NotificationReminderBloc() + ..add( + NotificationReminderEvent.initial( + reminder, + dateFormate, + timeFormate, + ), + ), + child: BlocBuilder( + builder: (context, state) { + if (state.status == NotificationReminderStatus.loading || + state.status == NotificationReminderStatus.initial) { + return const SizedBox.shrink(); + } + + if (state.status == NotificationReminderStatus.error) { + // error handle. + return const SizedBox.shrink(); + } + + final child = ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (_, selectedIds, child) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: selectedIds.contains(reminder.id) + ? ShapeDecoration( + color: const Color(0x1900BCF0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ) + : null, + child: child, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _InnerNotificationItem( + reminder: reminder, + ), + ), + ); + + return AnimatedGestureDetector( + scaleFactor: 0.99, + onTapUp: () { + if (mSelectedNotificationIds.value.contains(reminder.id)) { + mSelectedNotificationIds.value = mSelectedNotificationIds.value + ..remove(reminder.id); + } else { + mSelectedNotificationIds.value = mSelectedNotificationIds.value + ..add(reminder.id); + } + }, + child: child, + ); + }, + ), + ); + } +} + +class _InnerNotificationItem extends StatelessWidget { + const _InnerNotificationItem({ + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HSpace(10.0), + NotificationCheckIcon( + isSelected: mSelectedNotificationIds.value.contains(reminder.id), + ), + const HSpace(3.0), + !reminder.isRead ? const UnreadRedDot() : const HSpace(6.0), + const HSpace(3.0), + NotificationIcon(reminder: reminder), + const HSpace(12.0), + Expanded( + child: NotificationContent(reminder: reminder), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart index 469e7bfeb1..0f17bba68c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart @@ -1,18 +1,10 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; -import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/base/gesture.dart'; -import 'package:appflowy/mobile/presentation/notifications/widgets/color.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; -import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; -import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -60,55 +52,61 @@ class NotificationItem extends StatelessWidget { child: _SlidableNotificationItem( tabType: tabType, reminder: reminder, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const HSpace(8.0), - !reminder.isRead ? const _UnreadRedDot() : const HSpace(6.0), - const HSpace(4.0), - _NotificationIcon(reminder: reminder), - const HSpace(12.0), - Expanded( - child: _NotificationContent(reminder: reminder), - ), - ], + child: _InnerNotificationItem( + tabType: tabType, + reminder: reminder, ), ), ); - if (reminder.isRead) { - return child; - } - return AnimatedGestureDetector( scaleFactor: 0.99, - onTapUp: () => _onMarkAsRead(context), child: child, + onTapUp: () async { + final view = state.view; + if (view == null) { + return; + } + + await context.pushView(view); + + if (!reminder.isRead && context.mounted) { + context.read().add( + ReminderEvent.markAsRead([reminder.id]), + ); + } + }, ); }, ), ); } +} - void _onMarkAsRead(BuildContext context) { - if (reminder.isRead) { - return; - } +class _InnerNotificationItem extends StatelessWidget { + const _InnerNotificationItem({ + required this.reminder, + required this.tabType, + }); - showToastNotification( - context, - message: LocaleKeys.settings_notifications_markAsReadNotifications_success - .tr(), + final MobileNotificationTabType tabType; + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HSpace(8.0), + !reminder.isRead ? const UnreadRedDot() : const HSpace(6.0), + const HSpace(4.0), + NotificationIcon(reminder: reminder), + const HSpace(12.0), + Expanded( + child: NotificationContent(reminder: reminder), + ), + ], ); - - context.read().add( - ReminderEvent.update( - ReminderUpdate( - id: context.read().reminder.id, - isRead: true, - ), - ), - ); } } @@ -125,8 +123,6 @@ class _SlidableNotificationItem extends StatelessWidget { @override Widget build(BuildContext context) { - // only show the actions in the inbox tab - final List actions = switch (tabType) { MobileNotificationTabType.inbox => [ NotificationPaneActionType.more, @@ -166,201 +162,3 @@ class _SlidableNotificationItem extends StatelessWidget { ); } } - -const _kNotificationIconHeight = 36.0; - -class _NotificationIcon extends StatelessWidget { - const _NotificationIcon({ - required this.reminder, - }); - - final ReminderPB reminder; - - @override - Widget build(BuildContext context) { - return const FlowySvg( - FlowySvgs.m_notification_reminder_s, - size: Size.square(_kNotificationIconHeight), - blendMode: null, - ); - } -} - -class _UnreadRedDot extends StatelessWidget { - const _UnreadRedDot(); - - @override - Widget build(BuildContext context) { - return const SizedBox( - height: _kNotificationIconHeight, - child: Center( - child: SizedBox.square( - dimension: 6.0, - child: DecoratedBox( - decoration: ShapeDecoration( - color: Color(0xFFFF6331), - shape: OvalBorder(), - ), - ), - ), - ), - ); - } -} - -class _NotificationContent extends StatelessWidget { - const _NotificationContent({ - required this.reminder, - }); - - final ReminderPB reminder; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // title - _buildHeader(), - - // time & page name - _buildTimeAndPageName( - context, - state.createdAt, - state.pageTitle, - ), - - // content - Padding( - padding: const EdgeInsets.only(right: 16.0), - child: IntrinsicHeight( - child: BlocProvider( - create: (context) => DocumentPageStyleBloc(view: state.view!), - child: _NotificationDocumentContent(nodes: state.nodes), - ), - ), - ), - ], - ); - }, - ); - } - - Widget _buildHeader() { - return FlowyText.semibold( - LocaleKeys.settings_notifications_titles_reminder.tr(), - fontSize: 14, - figmaLineHeight: 20, - ); - } - - Widget _buildTimeAndPageName( - BuildContext context, - String createdAt, - String pageTitle, - ) { - return Opacity( - opacity: 0.5, - child: Row( - children: [ - // the legacy reminder doesn't contain the timestamp, so we don't show it - if (createdAt.isNotEmpty) ...[ - FlowyText.regular( - createdAt, - fontSize: 12, - figmaLineHeight: 18, - color: context.notificationItemTextColor, - ), - const _Ellipse(), - ], - FlowyText.regular( - pageTitle, - fontSize: 12, - figmaLineHeight: 18, - color: context.notificationItemTextColor, - ), - ], - ), - ); - } -} - -class _Ellipse extends StatelessWidget { - const _Ellipse(); - - @override - Widget build(BuildContext context) { - return Container( - width: 2.50, - height: 2.50, - margin: const EdgeInsets.symmetric(horizontal: 5.0), - decoration: ShapeDecoration( - color: context.notificationItemTextColor, - shape: const OvalBorder(), - ), - ); - } -} - -class _NotificationDocumentContent extends StatelessWidget { - const _NotificationDocumentContent({ - required this.nodes, - }); - - final List nodes; - - @override - Widget build(BuildContext context) { - final editorState = EditorState( - document: Document( - root: pageNode(children: nodes), - ), - ); - - final styleCustomizer = EditorStyleCustomizer( - context: context, - padding: EdgeInsets.zero, - ); - - final editorStyle = styleCustomizer.style().copyWith( - // hide the cursor - cursorColor: Colors.transparent, - cursorWidth: 0, - textStyleConfiguration: TextStyleConfiguration( - lineHeight: 22 / 14, - applyHeightToFirstAscent: true, - applyHeightToLastDescent: true, - text: TextStyle( - fontSize: 14, - color: context.notificationItemTextColor, - height: 22 / 14, - fontWeight: FontWeight.w400, - leadingDistribution: TextLeadingDistribution.even, - ), - ), - ); - - final blockBuilders = getEditorBuilderMap( - context: context, - editorState: editorState, - styleCustomizer: styleCustomizer, - // the editor is not editable in the chat - editable: false, - customHeadingPadding: EdgeInsets.zero, - ); - - return AppFlowyEditor( - editorState: editorState, - editorStyle: editorStyle, - disableSelectionService: true, - disableKeyboardService: true, - disableScrollService: true, - editable: false, - shrinkWrap: true, - blockComponentBuilders: blockBuilders, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart index 5ba72f6e7a..e378658887 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -5,6 +5,7 @@ import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -13,6 +14,8 @@ enum _NotificationSettingsPopupMenuItem { settings, markAllAsRead, archiveAll, + // only visible in debug mode + unarchiveAll; } class NotificationSettingsPopupMenu extends StatelessWidget { @@ -56,6 +59,15 @@ class NotificationSettingsPopupMenu extends StatelessWidget { svg: FlowySvgs.m_notification_archived_s, text: LocaleKeys.settings_notifications_settings_archiveAll.tr(), ), + // only visible in debug mode + if (kDebugMode) ...[ + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.unarchiveAll, + svg: FlowySvgs.m_notification_archived_s, + text: 'Unarchive all (Debug Mode)', + ), + ], ], onSelected: (_NotificationSettingsPopupMenuItem value) { switch (value) { @@ -68,6 +80,9 @@ class NotificationSettingsPopupMenu extends StatelessWidget { case _NotificationSettingsPopupMenuItem.settings: context.push(MobileHomeSettingPage.routeName); break; + case _NotificationSettingsPopupMenuItem.unarchiveAll: + _onUnarchiveAll(context); + break; } }, ); @@ -108,6 +123,19 @@ class NotificationSettingsPopupMenu extends StatelessWidget { context.read().add(const ReminderEvent.archiveAll()); } + + void _onUnarchiveAll(BuildContext context) { + if (!kDebugMode) { + return; + } + + showToastNotification( + context, + message: 'Unarchive all success (Debug Mode)', + ); + + context.read().add(const ReminderEvent.unarchiveAll()); + } } class _PopupButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart new file mode 100644 index 0000000000..27abc91241 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart @@ -0,0 +1,237 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const _kNotificationIconHeight = 36.0; + +class NotificationIcon extends StatelessWidget { + const NotificationIcon({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return const FlowySvg( + FlowySvgs.m_notification_reminder_s, + size: Size.square(_kNotificationIconHeight), + blendMode: null, + ); + } +} + +class NotificationCheckIcon extends StatelessWidget { + const NotificationCheckIcon({super.key, required this.isSelected}); + + final bool isSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _kNotificationIconHeight, + child: Center( + child: FlowySvg( + isSelected + ? FlowySvgs.m_notification_multi_select_s + : FlowySvgs.m_notification_multi_unselect_s, + blendMode: isSelected ? null : BlendMode.srcIn, + ), + ), + ); + } +} + +class UnreadRedDot extends StatelessWidget { + const UnreadRedDot({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: _kNotificationIconHeight, + child: Center( + child: SizedBox.square( + dimension: 6.0, + child: DecoratedBox( + decoration: ShapeDecoration( + color: Color(0xFFFF6331), + shape: OvalBorder(), + ), + ), + ), + ), + ); + } +} + +class NotificationContent extends StatelessWidget { + const NotificationContent({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // title + _buildHeader(), + + // time & page name + _buildTimeAndPageName( + context, + state.createdAt, + state.pageTitle, + ), + + // content + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: IntrinsicHeight( + child: BlocProvider( + create: (context) => DocumentPageStyleBloc(view: state.view!), + child: NotificationDocumentContent(nodes: state.nodes), + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildHeader() { + return FlowyText.semibold( + LocaleKeys.settings_notifications_titles_reminder.tr(), + fontSize: 14, + figmaLineHeight: 20, + ); + } + + Widget _buildTimeAndPageName( + BuildContext context, + String createdAt, + String pageTitle, + ) { + return Opacity( + opacity: 0.5, + child: Row( + children: [ + // the legacy reminder doesn't contain the timestamp, so we don't show it + if (createdAt.isNotEmpty) ...[ + FlowyText.regular( + createdAt, + fontSize: 12, + figmaLineHeight: 18, + color: context.notificationItemTextColor, + ), + const NotificationEllipse(), + ], + FlowyText.regular( + pageTitle, + fontSize: 12, + figmaLineHeight: 18, + color: context.notificationItemTextColor, + ), + ], + ), + ); + } +} + +class NotificationEllipse extends StatelessWidget { + const NotificationEllipse({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 2.50, + height: 2.50, + margin: const EdgeInsets.symmetric(horizontal: 5.0), + decoration: ShapeDecoration( + color: context.notificationItemTextColor, + shape: const OvalBorder(), + ), + ); + } +} + +class NotificationDocumentContent extends StatelessWidget { + const NotificationDocumentContent({ + super.key, + required this.nodes, + }); + + final List nodes; + + @override + Widget build(BuildContext context) { + final editorState = EditorState( + document: Document( + root: pageNode(children: nodes), + ), + ); + + final styleCustomizer = EditorStyleCustomizer( + context: context, + padding: EdgeInsets.zero, + ); + + final editorStyle = styleCustomizer.style().copyWith( + // hide the cursor + cursorColor: Colors.transparent, + cursorWidth: 0, + textStyleConfiguration: TextStyleConfiguration( + lineHeight: 22 / 14, + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + text: TextStyle( + fontSize: 14, + color: context.notificationItemTextColor, + height: 22 / 14, + fontWeight: FontWeight.w400, + leadingDistribution: TextLeadingDistribution.even, + ), + ), + ); + + final blockBuilders = getEditorBuilderMap( + context: context, + editorState: editorState, + styleCustomizer: styleCustomizer, + // the editor is not editable in the chat + editable: false, + customHeadingPadding: EdgeInsets.zero, + ); + + return IgnorePointer( + child: AppFlowyEditor( + editorState: editorState, + editorStyle: editorStyle, + disableSelectionService: true, + disableKeyboardService: true, + disableScrollService: true, + editable: false, + shrinkWrap: true, + blockComponentBuilders: blockBuilders, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart index 85eb55ae74..85f468c76c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart @@ -4,6 +4,7 @@ import 'package:appflowy/mobile/application/notification/notification_reminder_b import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; @@ -88,13 +89,21 @@ enum NotificationPaneActionType { showDivider: false, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, - builder: (context) { + builder: (_) { return MultiBlocProvider( providers: [ BlocProvider.value(value: reminderBloc), BlocProvider.value(value: notificationReminderBloc), ], - child: const _NotificationMoreActions(), + child: _NotificationMoreActions( + onClickMultipleChoice: () { + Future.delayed(const Duration(milliseconds: 250), () { + bottomNavigationBarType.value = + BottomNavigationBarActionType + .notificationMultiSelect; + }); + }, + ), ); }, ); @@ -105,7 +114,11 @@ enum NotificationPaneActionType { } class _NotificationMoreActions extends StatelessWidget { - const _NotificationMoreActions(); + const _NotificationMoreActions({ + required this.onClickMultipleChoice, + }); + + final VoidCallback onClickMultipleChoice; @override Widget build(BuildContext context) { @@ -172,6 +185,8 @@ class _NotificationMoreActions extends StatelessWidget { void _onMultipleChoice(BuildContext context) { Navigator.of(context).pop(); + + onClickMultipleChoice(); } void _onArchive(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart index 6c24a035db..7dda8f0a14 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -37,25 +37,28 @@ class _NotificationTabState extends State final reminders = _filterReminders(state.reminders); if (reminders.isEmpty) { + // add refresh indicator to the empty notification. return EmptyNotification( type: widget.tabType, ); } + final child = ListView.separated( + itemCount: reminders.length, + separatorBuilder: (context, index) => const VSpace(8.0), + itemBuilder: (context, index) { + final reminder = reminders[index]; + return NotificationItem( + key: ValueKey('${widget.tabType}_${reminder.id}'), + tabType: widget.tabType, + reminder: reminder, + ); + }, + ); + return RefreshIndicator.adaptive( onRefresh: () async => _onRefresh(context), - child: ListView.separated( - itemCount: reminders.length, - separatorBuilder: (context, index) => const VSpace(8.0), - itemBuilder: (context, index) { - final reminder = reminders[index]; - return NotificationItem( - key: ValueKey('${widget.tabType}_${reminder.id}'), - tabType: widget.tabType, - reminder: reminder, - ); - }, - ), + child: child, ); }, ); @@ -82,15 +85,53 @@ class _NotificationTabState extends State case MobileNotificationTabType.inbox: return reminders.reversed .where((reminder) => !reminder.isArchived) - .toList(); + .toList() + .unique((reminder) => reminder.id); case MobileNotificationTabType.archive: return reminders.reversed .where((reminder) => reminder.isArchived) - .toList(); + .toList() + .unique((reminder) => reminder.id); case MobileNotificationTabType.unread: return reminders.reversed .where((reminder) => !reminder.isRead) - .toList(); + .toList() + .unique((reminder) => reminder.id); } } } + +class MultiSelectNotificationTab extends StatelessWidget { + const MultiSelectNotificationTab({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // find the reminders that are not archived or read. + final reminders = state.reminders.reversed + .where((reminder) => !reminder.isArchived || !reminder.isRead) + .toList(); + + if (reminders.isEmpty) { + // add refresh indicator to the empty notification. + return const SizedBox.shrink(); + } + + return ListView.separated( + itemCount: reminders.length, + separatorBuilder: (context, index) => const VSpace(8.0), + itemBuilder: (context, index) { + final reminder = reminders[index]; + return MultiSelectNotificationItem( + key: ValueKey(reminder.id), + reminder: reminder, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart index 578e55892c..92cd83a74e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart @@ -1,6 +1,9 @@ export 'empty.dart'; export 'header.dart'; +export 'multi_select_notification_item.dart'; +export 'notification_item.dart'; export 'settings_popup_menu.dart'; +export 'shared.dart'; export 'slide_actions.dart'; export 'tab.dart'; export 'tab_bar.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart new file mode 100644 index 0000000000..2058e03e16 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class NavigationBarButton extends StatelessWidget { + const NavigationBarButton({ + super.key, + required this.text, + required this.icon, + required this.onTap, + this.enable = true, + }); + + final String text; + final FlowySvgData icon; + final VoidCallback onTap; + final bool enable; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: enable ? 1.0 : 0.3, + child: Container( + height: 40, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x3F1F2329)), + borderRadius: BorderRadius.circular(10), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + expandText: false, + iconPadding: 8, + leftIcon: FlowySvg(icon), + onTap: enable ? onTap : null, + text: FlowyText( + text, + fontSize: 15.0, + figmaLineHeight: 18.0, + fontWeight: FontWeight.w400, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 55e95393f6..127f365bf6 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -10,6 +10,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_multiple_select_page.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; @@ -92,6 +93,9 @@ GoRouter generateRouter(Widget child) { _mobileCalendarEventsPageRoute(), _mobileBlockSettingsPageRoute(), + + // notifications + _mobileNotificationMultiSelectPageRoute(), ], // Desktop and Mobile @@ -181,6 +185,18 @@ GoRoute _mobileHomeSettingPageRoute() { ); } +GoRoute _mobileNotificationMultiSelectPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileNotificationsMultiSelectScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: MobileNotificationsMultiSelectScreen(), + ); + }, + ); +} + GoRoute _mobileCloudSettingAppFlowyCloudPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index 8ac5ea9c11..6ea7a7aa7e 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -155,57 +155,46 @@ class ReminderBloc extends Bloc { ); } }, - markAllRead: () async { - final unreadReminders = state.reminders.where( - (reminder) => !reminder.isRead, - ); - - for (final reminder in unreadReminders) { - reminder.isRead = true; - await _reminderService.updateReminder(reminder: reminder); - } - - final reminder = [...state.reminders].map((e) { - if (e.isRead) { - return e; - } - e.freeze(); - return e.rebuild((update) { - update.isRead = true; - }); - }).toList(); - + markAsRead: (reminderIds) async { + final reminders = await _onMarkAsRead(reminderIds: reminderIds); emit( state.copyWith( - reminders: reminder, + reminders: reminders, + ), + ); + }, + archive: (reminderIds) async { + final reminders = await _onArchived( + isArchived: true, + reminderIds: reminderIds, + ); + emit( + state.copyWith( + reminders: reminders, + ), + ); + }, + markAllRead: () async { + final reminders = await _onMarkAsRead(); + emit( + state.copyWith( + reminders: reminders, ), ); }, archiveAll: () async { - final unArchivedReminders = state.reminders.where( - (reminder) => !reminder.isArchived, - ); - - for (final reminder in unArchivedReminders) { - reminder.isRead = true; - reminder.meta[ReminderMetaKeys.isArchived] = true.toString(); - await _reminderService.updateReminder(reminder: reminder); - } - - final reminder = [...state.reminders].map((e) { - if (e.isRead && e.isArchived) { - return e; - } - e.freeze(); - return e.rebuild((update) { - update.isRead = true; - update.meta[ReminderMetaKeys.isArchived] = true.toString(); - }); - }).toList(); - + final reminders = await _onArchived(isArchived: true); emit( state.copyWith( - reminders: reminder, + reminders: reminders, + ), + ); + }, + unarchiveAll: () async { + final reminders = await _onArchived(isArchived: false); + emit( + state.copyWith( + reminders: reminders, ), ); }, @@ -222,6 +211,96 @@ class ReminderBloc extends Bloc { ); } + /// Mark the reminder as read + /// + /// If the [reminderIds] is null, all unread reminders will be marked as read + /// Otherwise, only the reminders with the given IDs will be marked as read + Future> _onMarkAsRead({ + List? reminderIds, + }) async { + final Iterable remindersToUpdate; + + if (reminderIds != null) { + remindersToUpdate = state.reminders.where( + (reminder) => reminderIds.contains(reminder.id) && !reminder.isRead, + ); + } else { + // Get all reminders that are not matching the isArchived flag + remindersToUpdate = state.reminders.where( + (reminder) => !reminder.isRead, + ); + } + + for (final reminder in remindersToUpdate) { + reminder.isRead = true; + + await _reminderService.updateReminder(reminder: reminder); + Log.info('Mark reminder ${reminder.id} as read'); + } + + return state.reminders.map((e) { + if (reminderIds != null && !reminderIds.contains(e.id)) { + return e; + } + + if (e.isRead) { + return e; + } + + e.freeze(); + return e.rebuild((update) { + update.isRead = true; + }); + }).toList(); + } + + /// Archive or unarchive reminders + /// + /// If the [reminderIds] is null, all reminders will be archived + /// Otherwise, only the reminders with the given IDs will be archived or unarchived + Future> _onArchived({ + required bool isArchived, + List? reminderIds, + }) async { + final Iterable remindersToUpdate; + + if (reminderIds != null) { + remindersToUpdate = state.reminders.where( + (reminder) => + reminderIds.contains(reminder.id) && + reminder.isArchived != isArchived, + ); + } else { + // Get all reminders that are not matching the isArchived flag + remindersToUpdate = state.reminders.where( + (reminder) => reminder.isArchived != isArchived, + ); + } + + for (final reminder in remindersToUpdate) { + reminder.isRead = isArchived; + reminder.meta[ReminderMetaKeys.isArchived] = isArchived.toString(); + await _reminderService.updateReminder(reminder: reminder); + Log.info('Reminder ${reminder.id} is archived: $isArchived'); + } + + return state.reminders.map((e) { + if (reminderIds != null && !reminderIds.contains(e.id)) { + return e; + } + + if (e.isArchived == isArchived) { + return e; + } + + e.freeze(); + return e.rebuild((update) { + update.isRead = isArchived; + update.meta[ReminderMetaKeys.isArchived] = isArchived.toString(); + }); + }).toList(); + } + Timer _periodicCheck() { return Timer.periodic( const Duration(minutes: 1), @@ -285,17 +364,30 @@ class ReminderEvent with _$ReminderEvent { // Update a reminder (eg. isAck, isRead, etc.) const factory ReminderEvent.update(ReminderUpdate update) = _Update; - // Mark all unread reminders as read + // Event to mark specific reminders as read, takes a list of reminder IDs + const factory ReminderEvent.markAsRead(List reminderIds) = + _MarkAsRead; + + // Event to mark all unread reminders as read const factory ReminderEvent.markAllRead() = _MarkAllRead; + // Event to archive specific reminders, takes a list of reminder IDs + const factory ReminderEvent.archive(List reminderIds) = _Archive; + + // Event to archive all reminders const factory ReminderEvent.archiveAll() = _ArchiveAll; + // Event to unarchive all reminders + const factory ReminderEvent.unarchiveAll() = _UnarchiveAll; + + // Event to handle reminder press action const factory ReminderEvent.pressReminder({ required String reminderId, @Default(null) int? path, @Default(null) ViewPB? view, }) = _PressReminder; + // Event to refresh reminders const factory ReminderEvent.refresh() = _Refresh; } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 9e81ff2293..8d6a97d800 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: c8e0ca9 - resolved-ref: c8e0ca946b99b59286fabb811c39de5347f8bebd + ref: "7202c34" + resolved-ref: "7202c340724eef2c20e3f32ec75c0d91e4290cb0" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "3.1.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index e9468a0c5d..39c9a493d8 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.6.6 +version: 0.6.7 environment: flutter: ">=3.22.0" @@ -196,7 +196,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "c8e0ca9" + ref: "7202c34" appflowy_editor_plugins: git: diff --git a/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg b/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg index e8926358f0..5c06dd12c3 100644 --- a/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg +++ b/frontend/resources/flowy_icons/16x/m_notification_action_multiple_choice.svg @@ -1,4 +1,8 @@ - - + + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_multi_select.svg b/frontend/resources/flowy_icons/16x/m_notification_multi_select.svg new file mode 100644 index 0000000000..249d716cc1 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_multi_select.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/m_notification_multi_unselect.svg b/frontend/resources/flowy_icons/16x/m_notification_multi_unselect.svg new file mode 100644 index 0000000000..a0ae39fe15 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_notification_multi_unselect.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 090fda3bf7..d36fec34eb 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -964,7 +964,7 @@ }, "action": { "markAsRead": "Mark as read", - "multipleChoice": "Multiple choice", + "multipleChoice": "Select more", "archive": "Archive" }, "settings": {