Skip to content

Commit

Permalink
Add a notification page to display individual messages (#1069)
Browse files Browse the repository at this point in the history
* Add a notification page to display individual messages

* Use MultiBlocListener

* Move notifications navigation method

* Fix notifications page padding
  • Loading branch information
micahmo authored Jan 22, 2024
1 parent dbab0ef commit efe5c7f
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 54 deletions.
9 changes: 6 additions & 3 deletions lib/inbox/bloc/inbox_bloc.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -200,9 +201,11 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
List<CommentReplyView> 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);
}
Expand Down
1 change: 1 addition & 0 deletions lib/inbox/widgets/inbox_replies_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class _InboxRepliesViewState extends State<InboxRepliesView> {
}
},
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.replies.length,
Expand Down
38 changes: 14 additions & 24 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,19 +60,22 @@ void main() async {
DartPingIOS.register();
}

/// Allows the top-level notification handlers to trigger actions farther down
final StreamController<NotificationResponse> notificationsStreamController = StreamController<NotificationResponse>();

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.
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
// 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).
Expand All @@ -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();
Expand All @@ -95,7 +99,9 @@ void main() async {
}

class ThunderApp extends StatelessWidget {
const ThunderApp({super.key});
final Stream<NotificationResponse> notificationsStream;

const ThunderApp({super.key, required this.notificationsStream});

@override
Widget build(BuildContext context) {
Expand All @@ -113,6 +119,9 @@ class ThunderApp extends StatelessWidget {
BlocProvider(
create: (context) => DeepLinksCubit(),
),
BlocProvider(
create: (context) => NotificationsCubit(notificationsStream: notificationsStream),
),
BlocProvider(
create: (context) => ThunderBloc(),
),
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions lib/thunder/cubits/notifications_cubit/notifications_cubit.dart
Original file line number Diff line number Diff line change
@@ -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<NotificationsState> {
final Stream<NotificationResponse> notificationsStream;
StreamSubscription<NotificationResponse>? _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<String> 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();
}
}
29 changes: 29 additions & 0 deletions lib/thunder/cubits/notifications_cubit/notifications_state.dart
Original file line number Diff line number Diff line change
@@ -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<dynamic> get props => [
status,
replyId,
];
}
54 changes: 54 additions & 0 deletions lib/thunder/pages/notifications_pages.dart
Original file line number Diff line number Diff line change
@@ -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<CommentReplyView> 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: <Widget>[
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,
),
),
),
],
),
),
),
);
}
}
65 changes: 40 additions & 25 deletions lib/thunder/pages/thunder_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -103,6 +105,8 @@ class _ThunderState extends State<Thunder> {
handleSharedFilesAndText();
BlocProvider.of<DeepLinksCubit>(context).handleIncomingLinks();
BlocProvider.of<DeepLinksCubit>(context).handleInitialURI();

BlocProvider.of<NotificationsCubit>(context).handleNotifications();
});
}

Expand Down Expand Up @@ -399,32 +403,43 @@ class _ThunderState extends State<Thunder> {
],
child: WillPopScope(
onWillPop: () async => _handleBackButtonPress(scaffoldMessengerKey.currentState),
child: BlocListener<DeepLinksCubit, DeepLinksState>(
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<NotificationsCubit, NotificationsState>(
listener: (context, state) {
if (state.status == NotificationsStatus.reply) {
navigateToNotificationReplyPage(context, replyId: state.replyId);
}

case DeepLinkStatus.unknown:
showSnackbar(context, state.error ?? l10n.uriNotSupported);
}
},
},
),
BlocListener<DeepLinksCubit, DeepLinksState>(
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<ThunderBloc, ThunderState>(
builder: (context, thunderBlocState) {
reduceAnimations = thunderBlocState.reduceAnimations;
Expand Down
5 changes: 3 additions & 2 deletions lib/utils/notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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';
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';
Expand Down Expand Up @@ -90,7 +91,7 @@ Future<void> 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.
Expand Down
Loading

0 comments on commit efe5c7f

Please sign in to comment.