diff --git a/lib/inbox/bloc/inbox_bloc.dart b/lib/inbox/bloc/inbox_bloc.dart index bccb9e5ce..6b7599d75 100644 --- a/lib/inbox/bloc/inbox_bloc.dart +++ b/lib/inbox/bloc/inbox_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:lemmy_api_client/v3.dart'; @@ -200,9 +201,11 @@ class InboxBloc extends Bloc { List replies = List.from(state.replies); bool matchMarkedComment(CommentReplyView commentView) => commentView.commentReply.id == response.commentReplyView.commentReply.id; if (event.showAll) { - final CommentReplyView markedComment = replies.firstWhere(matchMarkedComment); - final int index = replies.indexOf(markedComment); - replies[index] = markedComment.copyWith(comment: response.commentReplyView.comment); + final CommentReplyView? markedComment = replies.firstWhereOrNull(matchMarkedComment); + if (markedComment != null) { + final int index = replies.indexOf(markedComment); + replies[index] = markedComment.copyWith(comment: response.commentReplyView.comment); + } } else { replies.removeWhere(matchMarkedComment); } diff --git a/lib/inbox/widgets/inbox_replies_view.dart b/lib/inbox/widgets/inbox_replies_view.dart index 2589e454d..1e90f5cf9 100644 --- a/lib/inbox/widgets/inbox_replies_view.dart +++ b/lib/inbox/widgets/inbox_replies_view.dart @@ -79,6 +79,7 @@ class _InboxRepliesViewState extends State { } }, child: ListView.builder( + padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: widget.replies.length, diff --git a/lib/main.dart b/lib/main.dart index 40c9d7957..8905cb835 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,7 @@ import 'package:thunder/core/enums/theme_type.dart'; import 'package:thunder/core/singletons/database.dart'; import 'package:thunder/core/theme/bloc/theme_bloc.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/thunder/cubits/notifications_cubit/notifications_cubit.dart'; import 'package:thunder/thunder/thunder.dart'; import 'package:thunder/user/bloc/user_bloc.dart'; import 'package:thunder/utils/cache.dart'; @@ -59,6 +60,9 @@ void main() async { DartPingIOS.register(); } + /// Allows the top-level notification handlers to trigger actions farther down + final StreamController notificationsStreamController = StreamController(); + if (!kIsWeb && Platform.isAndroid) { // Initialize local notifications. Note that this doesn't request permissions or actually send any notifications. // It's just hooking up callbacks and settings. @@ -66,12 +70,12 @@ void main() async { // Initialize the Android-specific settings, using the splash asset as the notification icon. const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); - await flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: onDidReceiveNotificationResponse); + await flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: (notificationResponse) => notificationsStreamController.add(notificationResponse)); // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. final NotificationAppLaunchDetails? notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); - if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails!.notificationResponse?.payload == repliesGroupKey) { - thunderPageController = PageController(initialPage: 3); + if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails!.notificationResponse != null) { + notificationsStreamController.add(notificationAppLaunchDetails.notificationResponse!); } // Initialize background fetch (this is async and can go run on its own). @@ -83,7 +87,7 @@ void main() async { final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; LemmyClient.instance.changeBaseUrl(initialInstance); - runApp(const ThunderApp()); + runApp(ThunderApp(notificationsStream: notificationsStreamController.stream)); // Set high refresh rate after app initialization FlutterDisplayMode.setHighRefreshRate(); @@ -95,7 +99,9 @@ void main() async { } class ThunderApp extends StatelessWidget { - const ThunderApp({super.key}); + final Stream notificationsStream; + + const ThunderApp({super.key, required this.notificationsStream}); @override Widget build(BuildContext context) { @@ -113,6 +119,9 @@ class ThunderApp extends StatelessWidget { BlocProvider( create: (context) => DeepLinksCubit(), ), + BlocProvider( + create: (context) => NotificationsCubit(notificationsStream: notificationsStream), + ), BlocProvider( create: (context) => ThunderBloc(), ), @@ -208,25 +217,6 @@ class ThunderApp extends StatelessWidget { } } -// ---------------- START LOCAL NOTIFICATION STUFF ---------------- // - -/// This seems to a notification callback handler that is only need for iOS. -void onDidReceiveLocalNotification(int id, String? title, String? body, String? payload) {} - -/// This is the notification handler that runs when a notification is tapped while the app is running. -void onDidReceiveNotificationResponse(NotificationResponse details) { - switch (details.payload) { - case repliesGroupKey: - // Navigate to the inbox page - thunderPageController.jumpToPage(3); - break; - default: - break; - } -} - -// ---------------- END LOCAL NOTIFICATION STUFF ---------------- // - // ---------------- START BACKGROUND FETCH STUFF ---------------- // /// This method handles "headless" callbacks, diff --git a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart new file mode 100644 index 000000000..3965e7440 --- /dev/null +++ b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:thunder/utils/notifications.dart'; + +part 'notifications_state.dart'; + +/// A cubit for handling notifications +class NotificationsCubit extends Cubit { + final Stream notificationsStream; + StreamSubscription? _notificationsStreamSubscription; + + NotificationsCubit({required this.notificationsStream}) : super(const NotificationsState()); + + void handleNotifications() { + _notificationsStreamSubscription = notificationsStream.listen((notificationResponse) async { + // Check if this is a reply notification + if (notificationResponse.payload?.contains(repliesGroupKey) == true) { + // Check if this is a specific notification for a specific reply + int? replyId; + final List parts = notificationResponse.payload!.split('-'); + if (parts.length == 2) { + replyId = int.tryParse(parts[1]); + } + + emit(state.copyWith(status: NotificationsStatus.reply, replyId: replyId)); + } + + // Reset the state + emit(state.copyWith(status: NotificationsStatus.none, replyId: null)); + }); + } + + void dispose() { + _notificationsStreamSubscription?.cancel(); + } +} diff --git a/lib/thunder/cubits/notifications_cubit/notifications_state.dart b/lib/thunder/cubits/notifications_cubit/notifications_state.dart new file mode 100644 index 000000000..0fc2ca99e --- /dev/null +++ b/lib/thunder/cubits/notifications_cubit/notifications_state.dart @@ -0,0 +1,29 @@ +part of 'notifications_cubit.dart'; + +enum NotificationsStatus { none, reply } + +class NotificationsState extends Equatable { + final NotificationsStatus status; + final int? replyId; + + const NotificationsState({ + this.status = NotificationsStatus.none, + this.replyId, + }); + + NotificationsState copyWith({ + required NotificationsStatus status, + required int? replyId, + }) { + return NotificationsState( + status: status, + replyId: replyId, + ); + } + + @override + List get props => [ + status, + replyId, + ]; +} diff --git a/lib/thunder/pages/notifications_pages.dart b/lib/thunder/pages/notifications_pages.dart new file mode 100644 index 000000000..5aafa0c83 --- /dev/null +++ b/lib/thunder/pages/notifications_pages.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:thunder/inbox/bloc/inbox_bloc.dart'; +import 'package:thunder/inbox/widgets/inbox_replies_view.dart'; +import 'package:thunder/post/bloc/post_bloc.dart'; + +/// A page for displaying the result of reply notifications +class NotificationsReplyPage extends StatelessWidget { + final List replies; + + const NotificationsReplyPage({super.key, required this.replies}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: InboxBloc()), + BlocProvider.value(value: PostBloc()), + ], + child: Container( + color: theme.colorScheme.background, + child: SafeArea( + top: false, + child: CustomScrollView( + slivers: [ + SliverAppBar( + flexibleSpace: const FlexibleSpaceBar(titlePadding: EdgeInsets.zero), + pinned: true, + title: ListTile( + title: Text(l10n.inbox, style: theme.textTheme.titleLarge), + subtitle: Text(l10n.reply(replies.length)), + ), + ), + SliverToBoxAdapter( + child: Material( + child: InboxRepliesView( + replies: replies, + showAll: true, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/thunder/pages/thunder_page.dart b/lib/thunder/pages/thunder_page.dart index fda6bc229..082e628b2 100644 --- a/lib/thunder/pages/thunder_page.dart +++ b/lib/thunder/pages/thunder_page.dart @@ -32,6 +32,7 @@ import 'package:thunder/feed/widgets/feed_fab.dart'; import 'package:thunder/post/utils/post.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/thunder/cubits/deep_links_cubit/deep_links_cubit.dart'; +import 'package:thunder/thunder/cubits/notifications_cubit/notifications_cubit.dart'; import 'package:thunder/thunder/enums/deep_link_enums.dart'; import 'package:thunder/thunder/widgets/bottom_nav_bar.dart'; import 'package:thunder/utils/constants.dart'; @@ -53,6 +54,7 @@ import 'package:thunder/utils/navigate_create_post.dart'; import 'package:thunder/utils/navigate_instance.dart'; import 'package:thunder/utils/navigate_post.dart'; import 'package:thunder/utils/navigate_user.dart'; +import 'package:thunder/utils/notifications_navigation.dart'; String? currentIntent; @@ -103,6 +105,8 @@ class _ThunderState extends State { handleSharedFilesAndText(); BlocProvider.of(context).handleIncomingLinks(); BlocProvider.of(context).handleInitialURI(); + + BlocProvider.of(context).handleNotifications(); }); } @@ -399,32 +403,43 @@ class _ThunderState extends State { ], child: WillPopScope( onWillPop: () async => _handleBackButtonPress(scaffoldMessengerKey.currentState), - child: BlocListener( - listener: (context, state) { - switch (state.deepLinkStatus) { - case DeepLinkStatus.loading: - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - scaffoldMessengerKey.currentState?.showSnackBar( - const SnackBar(content: CircularProgressIndicator.adaptive()), - ); - }); - - case DeepLinkStatus.empty: - showSnackbar(context, state.error ?? l10n.emptyUri); - case DeepLinkStatus.error: - showSnackbar(context, state.error ?? l10n.exceptionProcessingUri); - - case DeepLinkStatus.success: - try { - _handleDeepLinkNavigation(context, linkType: state.linkType, link: state.link); - } catch (e) { - _showLinkProcessingError(context, AppLocalizations.of(context)!.uriNotSupported, state.link!); + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state.status == NotificationsStatus.reply) { + navigateToNotificationReplyPage(context, replyId: state.replyId); } - - case DeepLinkStatus.unknown: - showSnackbar(context, state.error ?? l10n.uriNotSupported); - } - }, + }, + ), + BlocListener( + listener: (context, state) { + switch (state.deepLinkStatus) { + case DeepLinkStatus.loading: + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + scaffoldMessengerKey.currentState?.showSnackBar( + const SnackBar(content: CircularProgressIndicator.adaptive()), + ); + }); + + case DeepLinkStatus.empty: + showSnackbar(context, state.error ?? l10n.emptyUri); + case DeepLinkStatus.error: + showSnackbar(context, state.error ?? l10n.exceptionProcessingUri); + + case DeepLinkStatus.success: + try { + _handleDeepLinkNavigation(context, linkType: state.linkType, link: state.link); + } catch (e) { + _showLinkProcessingError(context, AppLocalizations.of(context)!.uriNotSupported, state.link!); + } + + case DeepLinkStatus.unknown: + showSnackbar(context, state.error ?? l10n.uriNotSupported); + } + }, + ), + ], child: BlocBuilder( builder: (context, thunderBlocState) { reduceAnimations = thunderBlocState.reduceAnimations; diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart index 0fb1bc61f..63c3c7826 100644 --- a/lib/utils/notifications.dart +++ b/lib/utils/notifications.dart @@ -3,6 +3,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:html/parser.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:markdown/markdown.dart'; + import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; import 'package:thunder/core/enums/full_name_separator.dart'; @@ -10,7 +12,6 @@ import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/utils/instance.dart'; -import 'package:markdown/markdown.dart'; const String _inboxMessagesChannelId = 'inbox_messages'; const String _inboxMessagesChannelName = 'Inbox Messages'; @@ -90,7 +91,7 @@ Future pollRepliesAndShowNotifications() async { // Body (body of comment) plaintextComment, notificationDetails, - payload: repliesGroupKey, // In the future, this could be a specific message ID for deep navigation + payload: '$repliesGroupKey-${commentReplyView.commentReply.id}', ); // Create a summary notification for the group. diff --git a/lib/utils/notifications_navigation.dart b/lib/utils/notifications_navigation.dart new file mode 100644 index 000000000..f9ace6106 --- /dev/null +++ b/lib/utils/notifications_navigation.dart @@ -0,0 +1,56 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:swipeable_page_route/swipeable_page_route.dart'; + +import 'package:thunder/account/models/account.dart'; +import 'package:thunder/core/auth/helpers/fetch_account.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/thunder/pages/notifications_pages.dart'; + +void navigateToNotificationReplyPage(BuildContext context, {required int? replyId}) async { + final ThunderBloc thunderBloc = context.read(); + final bool reduceAnimations = thunderBloc.state.reduceAnimations; + final Account? account = await fetchActiveProfileAccount(); + + List allReplies = []; + CommentReplyView? specificReply; + + bool doneFetching = false; + int currentPage = 1; + + // Load the notifications + while (!doneFetching) { + final GetRepliesResponse getRepliesResponse = await LemmyClient.instance.lemmyApiV3.run(GetReplies( + sort: CommentSortType.new_, + page: currentPage, + limit: 50, + unreadOnly: replyId == null, + auth: account?.jwt, + )); + + allReplies.addAll(getRepliesResponse.replies); + specificReply ??= getRepliesResponse.replies.firstWhereOrNull((crv) => crv.commentReply.id == replyId); + + doneFetching = specificReply != null || getRepliesResponse.replies.isEmpty; + ++currentPage; + } + + if (context.mounted) { + Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + backGestureDetectionWidth: 45, + canOnlySwipeFromEdge: !thunderBloc.state.enableFullScreenSwipeNavigationGesture, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: thunderBloc), + ], + child: NotificationsReplyPage(replies: specificReply == null ? allReplies : [specificReply]), + ), + ), + ); + } +}