added guardian letters with chat and multiple answer functionalities
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:app_settings/app_settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../session/session_manager.dart';
|
||||
import '../state/app/modules/app_modules.dart';
|
||||
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../storage/notification_settings.dart';
|
||||
import '../widget/confirm_dialog.dart';
|
||||
import 'push_registration.dart';
|
||||
|
||||
const _talkExplanation =
|
||||
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
|
||||
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
|
||||
'sie, um Push-Nachrichten zu erhalten.';
|
||||
const _talkDeclinedNote =
|
||||
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
|
||||
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
|
||||
'deines Geräts nachträglich aktivieren.';
|
||||
const _parentLetterExplanation =
|
||||
'Damit du keine Elternbriefe der Schule verpasst, fragen wir dich gleich '
|
||||
'nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere sie, um '
|
||||
'Push-Nachrichten zu erhalten.';
|
||||
const _parentLetterDeclinedNote =
|
||||
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
|
||||
'Elternbriefen. Du kannst sie jederzeit in den Systemeinstellungen deines '
|
||||
'Geräts nachträglich aktivieren.';
|
||||
|
||||
/// The occasions with a guided notification-permission flow. Each runs at
|
||||
/// most once per install, guarded by its own flag in [NotificationSettings].
|
||||
enum _PermissionPrompt {
|
||||
talkVisit(_talkExplanation, _talkDeclinedNote),
|
||||
guardianLogin(
|
||||
_parentLetterExplanation,
|
||||
_parentLetterDeclinedNote,
|
||||
module: Modules.parentLetters,
|
||||
spentOnDisplay: true,
|
||||
),
|
||||
parentLettersVisit(
|
||||
_parentLetterExplanation,
|
||||
_parentLetterDeclinedNote,
|
||||
module: Modules.parentLetters,
|
||||
);
|
||||
|
||||
const _PermissionPrompt(
|
||||
this.explanation,
|
||||
this.declinedNote, {
|
||||
this.module,
|
||||
this.spentOnDisplay = false,
|
||||
});
|
||||
|
||||
final String explanation;
|
||||
final String declinedNote;
|
||||
|
||||
/// Only sessions that have this module are asked.
|
||||
final Modules? module;
|
||||
|
||||
/// Spends the one-shot as soon as the explanation is displayed, so a
|
||||
/// dismissed dialog does not come back on every app start.
|
||||
final bool spentOnDisplay;
|
||||
|
||||
bool wasShown(NotificationSettings settings) => switch (this) {
|
||||
talkVisit => settings.talkPermissionPromptShown,
|
||||
guardianLogin => settings.guardianLoginPromptShown,
|
||||
parentLettersVisit => settings.parentLettersPromptShown,
|
||||
};
|
||||
|
||||
void markShown(NotificationSettings settings) {
|
||||
switch (this) {
|
||||
case talkVisit:
|
||||
settings.talkPermissionPromptShown = true;
|
||||
case guardianLogin:
|
||||
settings.guardianLoginPromptShown = true;
|
||||
case parentLettersVisit:
|
||||
settings.parentLettersPromptShown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the one-time notification-permission flow on the first Talk visit.
|
||||
///
|
||||
/// The OS prompt is deliberately kept out of the cold-start path (younger users
|
||||
/// decline it reflexively before ever seeing why they'd want it). Instead, the
|
||||
/// first time Talk is opened we explain the request, then trigger the OS prompt,
|
||||
/// and — if declined — offer a shortcut to the system settings.
|
||||
///
|
||||
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
|
||||
Future<void> maybePromptTalkNotifications(BuildContext context) =>
|
||||
_maybePrompt(context, _PermissionPrompt.talkVisit);
|
||||
|
||||
/// Accounts that receive parent letters never reach the Talk flow, so they
|
||||
/// are asked once right after signing in.
|
||||
Future<void> maybePromptGuardianLoginNotifications(BuildContext context) =>
|
||||
_maybePrompt(context, _PermissionPrompt.guardianLogin);
|
||||
|
||||
/// Second chance with context: explains the request once more on the first
|
||||
/// visit of the parent letters when the permission is still missing.
|
||||
Future<void> maybePromptParentLetterNotifications(BuildContext context) =>
|
||||
_maybePrompt(context, _PermissionPrompt.parentLettersVisit);
|
||||
|
||||
bool _promptInFlight = false;
|
||||
|
||||
Future<void> _maybePrompt(
|
||||
BuildContext context,
|
||||
_PermissionPrompt prompt,
|
||||
) async {
|
||||
final module = prompt.module;
|
||||
if (module != null &&
|
||||
!AppModule.isAvailableFor(module, SessionManager().current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final notificationSettings = settings.val().notificationSettings;
|
||||
|
||||
// Already handled once, or the user opted out of push entirely.
|
||||
if (prompt.wasShown(notificationSettings)) return;
|
||||
if (!notificationSettings.enabled) return;
|
||||
|
||||
// Capabilities may still be loading on a fresh cold start; retry on the next
|
||||
// occasion instead of burning the one-shot flag.
|
||||
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
|
||||
|
||||
if (_promptInFlight) return;
|
||||
_promptInFlight = true;
|
||||
try {
|
||||
// Users who already granted the permission: register silently and mark the
|
||||
// prompt as handled without showing any dialog.
|
||||
if (await PushRegistration.isOsPermissionGranted()) {
|
||||
prompt.markShown(settings.val(write: true).notificationSettings);
|
||||
unawaited(PushRegistration().register());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (prompt.spentOnDisplay) {
|
||||
prompt.markShown(settings.val(write: true).notificationSettings);
|
||||
}
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: ConfirmDialog(
|
||||
icon: Icons.notifications_active_outlined,
|
||||
title: 'Benachrichtigungen aktivieren',
|
||||
content: prompt.explanation,
|
||||
confirmButton: 'Weiter',
|
||||
cancelButton: null,
|
||||
onConfirm: () =>
|
||||
unawaited(_requestPermission(context, settings, prompt)),
|
||||
).build,
|
||||
);
|
||||
} finally {
|
||||
_promptInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermission(
|
||||
BuildContext context,
|
||||
SettingsCubit settings,
|
||||
_PermissionPrompt prompt,
|
||||
) async {
|
||||
final granted = await PushRegistration.requestOsPermission();
|
||||
|
||||
// Mark handled regardless of the outcome — the user can re-enable later via
|
||||
// the system settings; we don't want to prompt again on the next occasion.
|
||||
prompt.markShown(settings.val(write: true).notificationSettings);
|
||||
|
||||
if (granted) {
|
||||
unawaited(PushRegistration().register());
|
||||
return;
|
||||
}
|
||||
|
||||
log('Push: notification permission declined on ${prompt.name}');
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
ConfirmDialog(
|
||||
icon: Icons.notifications_off_outlined,
|
||||
title: 'Benachrichtigungen deaktiviert',
|
||||
content: prompt.declinedNote,
|
||||
confirmButton: 'Einstellungen öffnen',
|
||||
cancelButton: 'Später',
|
||||
onConfirm: () =>
|
||||
AppSettings.openAppSettings(type: AppSettingsType.notification),
|
||||
).asDialog(context);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'nid_store.dart';
|
||||
import 'push_actions.dart';
|
||||
import 'push_avatar.dart';
|
||||
import 'push_subject.dart';
|
||||
import 'push_target.dart';
|
||||
|
||||
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
|
||||
/// notifications. Talk messages of one chat stack into a SINGLE
|
||||
@@ -23,6 +24,8 @@ class PushRenderer {
|
||||
static const talkChannelName = 'Talk-Nachrichten';
|
||||
static const generalChannelId = 'nextcloud_general';
|
||||
static const generalChannelName = 'Benachrichtigungen';
|
||||
static const parentLettersChannelId = 'parent_letters';
|
||||
static const parentLettersChannelName = 'Elternbriefe';
|
||||
|
||||
static const String iosTalkCategory = 'TALK_MESSAGE';
|
||||
|
||||
@@ -67,6 +70,14 @@ class PushRenderer {
|
||||
description: 'Allgemeine Benachrichtigungen',
|
||||
),
|
||||
);
|
||||
await android.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
parentLettersChannelId,
|
||||
parentLettersChannelName,
|
||||
description: 'Neue Elternbriefe und Antworten der Schule',
|
||||
importance: Importance.high,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a decrypted Nextcloud push subject.
|
||||
@@ -350,23 +361,35 @@ class PushRenderer {
|
||||
required String body,
|
||||
Map<String, String>? data,
|
||||
}) async {
|
||||
final id = _fallbackId('$title$body');
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
generalChannelId,
|
||||
generalChannelName,
|
||||
final parentLetterId = data?[parentLetterIdKey];
|
||||
final isParentLetter =
|
||||
data?['type'] == parentLetterPushType &&
|
||||
parentLetterId != null &&
|
||||
parentLetterId.isNotEmpty;
|
||||
// One notification per letter: a reply replaces the letter's earlier one.
|
||||
final id = isParentLetter
|
||||
? parentLetterNotificationId(parentLetterId)
|
||||
: _fallbackId('$title$body');
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
isParentLetter ? parentLettersChannelId : generalChannelId,
|
||||
isParentLetter ? parentLettersChannelName : generalChannelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
color: _accentColor,
|
||||
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
|
||||
);
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: const NotificationDetails(android: androidDetails),
|
||||
notificationDetails: NotificationDetails(android: androidDetails),
|
||||
payload: data == null ? null : jsonEncode(data),
|
||||
);
|
||||
}
|
||||
|
||||
static int parentLetterNotificationId(String letterId) =>
|
||||
stableChatNotificationId('parent-letter:$letterId');
|
||||
|
||||
String _payload({required String? chatToken, required int nid}) =>
|
||||
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
||||
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'push_actions.dart';
|
||||
import 'push_target.dart';
|
||||
|
||||
/// Routes foreground notification interactions from the single
|
||||
/// flutter_local_notifications response callback. Action responses (reply /
|
||||
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
|
||||
/// the target chat token via [pendingChatToken] for [App] to navigate to.
|
||||
/// its target via [pendingTarget] for [App] to navigate to.
|
||||
class PushTapRouter {
|
||||
PushTapRouter._();
|
||||
|
||||
/// Chat token of the most recently tapped Talk notification, or null. [App]
|
||||
/// listens to this and opens the chat, then resets it to null.
|
||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
||||
|
||||
/// Newsletter id of the most recently tapped Marianum-Message notification,
|
||||
/// or null. [App] listens to this and opens the message, then resets it.
|
||||
static final ValueNotifier<String?> pendingNewsletterId = ValueNotifier(null);
|
||||
/// Target of the most recently tapped notification, or null. [App] listens
|
||||
/// to this and navigates, then resets it to null.
|
||||
static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
|
||||
|
||||
static void handleResponse(NotificationResponse response) {
|
||||
final actionId = response.actionId;
|
||||
@@ -29,13 +27,28 @@ class PushTapRouter {
|
||||
}
|
||||
final map = _payloadMap(response.payload);
|
||||
if (map == null) return;
|
||||
final newsletterId = _stringValue(map, 'newsletterId');
|
||||
if (newsletterId != null) {
|
||||
pendingNewsletterId.value = newsletterId;
|
||||
return;
|
||||
final target = resolvePushTarget(map);
|
||||
if (target != null) pendingTarget.value = target;
|
||||
}
|
||||
|
||||
static bool _launchHandled = false;
|
||||
|
||||
/// Routes the tap that cold-started the app. The plugin reports such a tap
|
||||
/// only through its launch details, never through the response callback;
|
||||
/// the details stay set for the whole process, hence the one-shot guard.
|
||||
static Future<void> handleAppLaunch(
|
||||
FlutterLocalNotificationsPlugin plugin,
|
||||
) async {
|
||||
if (_launchHandled) return;
|
||||
_launchHandled = true;
|
||||
try {
|
||||
final details = await plugin.getNotificationAppLaunchDetails();
|
||||
final response = details?.notificationResponse;
|
||||
if (details?.didNotificationLaunchApp != true || response == null) return;
|
||||
handleResponse(response);
|
||||
} on Object catch (e) {
|
||||
log('Reading the notification launch details failed: $e');
|
||||
}
|
||||
final token = _stringValue(map, 'chatToken');
|
||||
if (token != null) pendingChatToken.value = token;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _payloadMap(String? payload) {
|
||||
@@ -46,9 +59,4 @@ class PushTapRouter {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _stringValue(Map<String, dynamic> map, String key) {
|
||||
final value = map[key];
|
||||
return value is String && value.isNotEmpty ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/// `type` of the visible Connect push for a parent letter. Its data carries
|
||||
/// the letter under [parentLetterIdKey].
|
||||
const String parentLetterPushType = 'parent-letter';
|
||||
const String parentLetterIdKey = 'parentLetterId';
|
||||
|
||||
/// Where a tapped notification leads.
|
||||
sealed class PushTarget {
|
||||
const PushTarget();
|
||||
}
|
||||
|
||||
class ParentLetterTarget extends PushTarget {
|
||||
final String letterId;
|
||||
const ParentLetterTarget(this.letterId);
|
||||
}
|
||||
|
||||
class NewsletterTarget extends PushTarget {
|
||||
final String newsletterId;
|
||||
const NewsletterTarget(this.newsletterId);
|
||||
}
|
||||
|
||||
class ChatTarget extends PushTarget {
|
||||
final String chatToken;
|
||||
const ChatTarget(this.chatToken);
|
||||
}
|
||||
|
||||
/// Resolves the data of a tapped notification — the payload of a locally
|
||||
/// rendered one as well as the data of an FCM message. Null when it names no
|
||||
/// known target.
|
||||
PushTarget? resolvePushTarget(Map<String, dynamic> data) {
|
||||
String? value(String key) {
|
||||
final value = data[key];
|
||||
return value is String && value.isNotEmpty ? value : null;
|
||||
}
|
||||
|
||||
if (value(parentLetterIdKey) case final letterId?) {
|
||||
return ParentLetterTarget(letterId);
|
||||
}
|
||||
if (value('newsletterId') case final newsletterId?) {
|
||||
return NewsletterTarget(newsletterId);
|
||||
}
|
||||
for (final key in const ['chatToken', 'token', 'roomToken']) {
|
||||
if (value(key) case final chatToken?) return ChatTarget(chatToken);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user