Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a notification page to display individual messages #1069

Merged
merged 5 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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,
];
}
57 changes: 57 additions & 0 deletions lib/thunder/pages/notifications_pages.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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: Transform(
micahmo marked this conversation as resolved.
Show resolved Hide resolved
transform: Matrix4.translationValues(0, -24, 0),
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.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>(
micahmo marked this conversation as resolved.
Show resolved Hide resolved
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
Loading
Loading